qualia-framework 4.5.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/AGENTS.md +24 -0
  2. package/CLAUDE.md +12 -75
  3. package/README.md +23 -16
  4. package/agents/builder.md +9 -21
  5. package/agents/planner.md +8 -0
  6. package/agents/verifier.md +8 -0
  7. package/agents/visual-evaluator.md +132 -0
  8. package/bin/cli.js +54 -18
  9. package/bin/install.js +369 -29
  10. package/bin/qualia-ui.js +208 -1
  11. package/bin/slop-detect.mjs +5 -0
  12. package/bin/state.js +34 -1
  13. package/docs/install-redesign-builder-prompt.md +290 -0
  14. package/docs/install-redesign-pilot.md +234 -0
  15. package/docs/playwright-loop-builder-prompt.md +185 -0
  16. package/docs/playwright-loop-design-notes.md +108 -0
  17. package/docs/playwright-loop-pilot-results.md +170 -0
  18. package/docs/playwright-loop-review-2026-05-03.md +65 -0
  19. package/docs/playwright-loop-tester-prompt.md +213 -0
  20. package/docs/reviews/matt-pocock-skills-analysis.md +300 -0
  21. package/guide.md +9 -5
  22. package/hooks/env-empty-guard.js +74 -0
  23. package/hooks/pre-compact.js +19 -9
  24. package/hooks/pre-deploy-gate.js +8 -2
  25. package/hooks/pre-push.js +26 -12
  26. package/hooks/supabase-destructive-guard.js +62 -0
  27. package/hooks/vercel-account-guard.js +91 -0
  28. package/package.json +2 -1
  29. package/rules/design-brand.md +4 -0
  30. package/rules/design-laws.md +4 -0
  31. package/rules/design-product.md +4 -0
  32. package/rules/design-rubric.md +4 -0
  33. package/rules/grounding.md +4 -0
  34. package/skills/qualia-build/SKILL.md +40 -46
  35. package/skills/qualia-discuss/SKILL.md +51 -68
  36. package/skills/qualia-handoff/SKILL.md +1 -0
  37. package/skills/qualia-issues/SKILL.md +151 -0
  38. package/skills/qualia-map/SKILL.md +78 -35
  39. package/skills/qualia-new/REFERENCE.md +139 -0
  40. package/skills/qualia-new/SKILL.md +45 -121
  41. package/skills/qualia-optimize/REFERENCE.md +202 -0
  42. package/skills/qualia-optimize/SKILL.md +72 -237
  43. package/skills/qualia-plan/SKILL.md +58 -65
  44. package/skills/qualia-polish-loop/REFERENCE.md +265 -0
  45. package/skills/qualia-polish-loop/SKILL.md +201 -0
  46. package/skills/qualia-polish-loop/fixtures/broken.html +117 -0
  47. package/skills/qualia-polish-loop/fixtures/clean.html +196 -0
  48. package/skills/qualia-polish-loop/scripts/loop.mjs +302 -0
  49. package/skills/qualia-polish-loop/scripts/playwright-capture.mjs +197 -0
  50. package/skills/qualia-polish-loop/scripts/score.mjs +176 -0
  51. package/skills/qualia-report/SKILL.md +141 -200
  52. package/skills/qualia-research/SKILL.md +28 -33
  53. package/skills/qualia-road/SKILL.md +103 -0
  54. package/skills/qualia-ship/SKILL.md +1 -0
  55. package/skills/qualia-task/SKILL.md +1 -1
  56. package/skills/qualia-test/SKILL.md +50 -2
  57. package/skills/qualia-triage/SKILL.md +152 -0
  58. package/skills/qualia-verify/SKILL.md +63 -104
  59. package/skills/qualia-zoom/SKILL.md +51 -0
  60. package/skills/zoho-workflow/SKILL.md +1 -1
  61. package/templates/CONTEXT.md +36 -0
  62. package/templates/decisions/ADR-template.md +30 -0
  63. package/tests/bin.test.sh +451 -7
  64. package/tests/state.test.sh +58 -0
@@ -0,0 +1,196 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Lumen Audit · case study</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300..900&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: oklch(0.16 0.012 220);
13
+ --bg-2: oklch(0.20 0.014 220);
14
+ --surface: oklch(0.24 0.014 220);
15
+ --text: oklch(0.94 0.006 220);
16
+ --muted: oklch(0.66 0.010 220);
17
+ --dim: oklch(0.50 0.012 220);
18
+ --line: oklch(0.32 0.012 220);
19
+ --accent: oklch(0.78 0.14 196);
20
+ --accent-2: oklch(0.70 0.13 196);
21
+
22
+ --shadow-sm: 0 1px 2px oklch(0.10 0.020 220 / 0.40);
23
+ --shadow-md: 0 8px 24px -8px oklch(0.10 0.030 220 / 0.50);
24
+ --shadow-lg: 0 20px 60px -20px oklch(0.10 0.035 220 / 0.60);
25
+
26
+ --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
27
+ }
28
+ * { box-sizing: border-box; }
29
+ html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); }
30
+ body {
31
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
32
+ font-size: clamp(0.95rem, 0.5rem + 0.5vw, 1.0625rem);
33
+ line-height: 1.55;
34
+ font-feature-settings: "tnum" 1, "ss01" 1;
35
+ -webkit-font-smoothing: antialiased;
36
+ }
37
+ header, main, footer {
38
+ padding-inline: clamp(1.25rem, 5vw, 4rem);
39
+ padding-block: clamp(1.5rem, 4vw, 3rem);
40
+ }
41
+ a { color: var(--text); text-decoration: none; transition: color 150ms var(--ease-out); }
42
+ a:hover { color: var(--accent); }
43
+ a:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; border-radius: 2px; }
44
+
45
+ header {
46
+ display: grid;
47
+ grid-template-columns: auto 1fr auto;
48
+ align-items: baseline;
49
+ gap: clamp(1rem, 3vw, 2.5rem);
50
+ border-bottom: 1px solid var(--line);
51
+ }
52
+ .logo {
53
+ font-family: 'Fraunces', serif;
54
+ font-weight: 600;
55
+ font-size: 1.25rem;
56
+ letter-spacing: -0.02em;
57
+ }
58
+ .logo span { color: var(--accent); }
59
+ nav { display: flex; gap: clamp(1rem, 2vw, 2rem); justify-self: end; font-size: 0.875rem; color: var(--muted); }
60
+ nav a:hover { color: var(--text); }
61
+
62
+ .hero {
63
+ display: grid;
64
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
65
+ gap: clamp(1.5rem, 4vw, 4rem);
66
+ align-items: end;
67
+ padding-block: clamp(3rem, 8vw, 7rem);
68
+ border-bottom: 1px solid var(--line);
69
+ }
70
+ @media (max-width: 720px) { .hero { grid-template-columns: 1fr; } }
71
+ h1 {
72
+ font-family: 'Fraunces', serif;
73
+ font-weight: 350;
74
+ font-variation-settings: "opsz" 144;
75
+ font-size: clamp(2.25rem, 1rem + 6vw, 5.25rem);
76
+ line-height: 1.02;
77
+ letter-spacing: -0.035em;
78
+ margin: 0 0 1.5rem;
79
+ max-width: 16ch;
80
+ }
81
+ h1 em {
82
+ font-style: italic;
83
+ font-weight: 400;
84
+ color: var(--accent);
85
+ }
86
+ .lede { color: var(--muted); max-width: 38ch; font-size: 1rem; }
87
+ .meta {
88
+ align-self: end;
89
+ display: grid;
90
+ gap: 0.5rem;
91
+ font-size: 0.8125rem;
92
+ color: var(--dim);
93
+ text-transform: uppercase;
94
+ letter-spacing: 0.08em;
95
+ }
96
+ .meta dt { color: var(--muted); }
97
+ .meta dd { margin: 0; color: var(--text); font-variant-numeric: tabular-nums; letter-spacing: 0.02em; }
98
+
99
+ .work { padding-block: clamp(3rem, 7vw, 6rem); }
100
+ .work h2 {
101
+ font-family: 'Fraunces', serif;
102
+ font-weight: 400;
103
+ font-size: clamp(1.5rem, 0.5rem + 2.5vw, 2.5rem);
104
+ letter-spacing: -0.02em;
105
+ margin: 0 0 2.5rem;
106
+ }
107
+ .work-grid {
108
+ display: grid;
109
+ grid-template-columns: repeat(12, 1fr);
110
+ gap: 1.25rem;
111
+ }
112
+ .work-grid > article { background: var(--surface); border: 1px solid var(--line); padding: 1.5rem; transition: transform 200ms var(--ease-out), box-shadow 200ms var(--ease-out); }
113
+ .work-grid > article:hover { transform: translateY(-2px); box-shadow: var(--shadow-md); }
114
+ .item-1 { grid-column: span 7; min-height: 18rem; display: grid; align-content: end; }
115
+ .item-2 { grid-column: span 5; min-height: 18rem; }
116
+ .item-3 { grid-column: span 5; min-height: 14rem; }
117
+ .item-4 { grid-column: span 7; min-height: 14rem; }
118
+ @media (max-width: 720px) { .item-1, .item-2, .item-3, .item-4 { grid-column: span 12; min-height: 11rem; } }
119
+ .work-grid h3 { font-family: 'Fraunces', serif; font-weight: 400; font-size: 1.5rem; letter-spacing: -0.02em; margin: 0 0 0.5rem; }
120
+ .work-grid p { color: var(--muted); margin: 0; font-size: 0.9375rem; }
121
+ .work-grid .num { color: var(--dim); font-size: 0.75rem; letter-spacing: 0.12em; text-transform: uppercase; margin-bottom: 1rem; }
122
+
123
+ footer { border-top: 1px solid var(--line); display: grid; grid-template-columns: 1fr auto; gap: 1rem; align-items: end; color: var(--dim); font-size: 0.8125rem; }
124
+ footer a:hover { color: var(--accent); }
125
+
126
+ @media (prefers-reduced-motion: reduce) {
127
+ *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
128
+ }
129
+ </style>
130
+ </head>
131
+ <body>
132
+ <header>
133
+ <span class="logo">Lumen<span>·</span></span>
134
+ <nav>
135
+ <a href="#work">Work</a>
136
+ <a href="#process">Process</a>
137
+ <a href="#contact">Get in touch</a>
138
+ </nav>
139
+ <a class="logo" href="#contact" style="color: var(--accent); font-size: 0.875rem;">Audit a brand</a>
140
+ </header>
141
+
142
+ <main>
143
+ <section class="hero">
144
+ <div>
145
+ <h1>Brands that age <em>well</em>, on the second glance.</h1>
146
+ <p class="lede">Lumen audits the visual language of design-led companies. We score against a 64-point rubric and ship the rewrite, not the slide deck.</p>
147
+ </div>
148
+ <dl class="meta">
149
+ <dt>Engagements</dt>
150
+ <dd>148 since 2019</dd>
151
+ <dt>Average lead time</dt>
152
+ <dd>11 days</dd>
153
+ <dt>Repeat-customer rate</dt>
154
+ <dd>72%</dd>
155
+ </dl>
156
+ </section>
157
+
158
+ <section class="work" id="work">
159
+ <h2>Recent audits, picked because they read</h2>
160
+ <div class="work-grid">
161
+ <article class="item-1">
162
+ <span class="num">01 / Fintech, NL</span>
163
+ <h3>Onyx Treasury</h3>
164
+ <p>Replaced a navy-and-gold palette saturated with sector clichés. Shipped a single-hue OKLCH system, restored 12 of 14 contrast pairs, gave the dashboard six months of breathing room.</p>
165
+ </article>
166
+ <article class="item-2">
167
+ <span class="num">02 / Healthcare, IE</span>
168
+ <h3>Verge Diagnostics</h3>
169
+ <p>Removed pastel teal. Rebuilt around editorial Fraunces and a single accent pulled from the diagnostic chart. Saved 41kb of icon font weight.</p>
170
+ </article>
171
+ <article class="item-3">
172
+ <span class="num">03 / Voice AI, US</span>
173
+ <h3>Hum.</h3>
174
+ <p>Pulled motion back from elastic-spring chaos to one signature reveal per page. Kept the personality, lost the seasickness.</p>
175
+ </article>
176
+ <article class="item-4">
177
+ <span class="num">04 / Restaurant group, CY</span>
178
+ <h3>Olive &amp; Bone</h3>
179
+ <p>One typeface (Fraunces) across the brand, the menu, the wayfinding, the receipts. The legibility budget went to text, not chrome.</p>
180
+ </article>
181
+ </div>
182
+ </section>
183
+ </main>
184
+
185
+ <footer id="contact">
186
+ <div>
187
+ <span class="logo" style="font-size: 1rem;">Lumen<span>·</span></span>
188
+ <p style="margin: 0.5rem 0 0; max-width: 40ch;">Brand audits with a real opinion. We say no to projects we can&rsquo;t move forward.</p>
189
+ </div>
190
+ <div style="text-align: right;">
191
+ <a href="mailto:hello@lumenaudit.studio">hello@lumenaudit.studio</a><br>
192
+ <span>2026 Lumen Audit, Nicosia + Amsterdam</span>
193
+ </div>
194
+ </footer>
195
+ </body>
196
+ </html>
@@ -0,0 +1,302 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * loop.mjs — orchestrator state-machine for /qualia-polish-loop.
4
+ *
5
+ * Claude (the parent session) drives the loop by issuing CLI commands. This
6
+ * script keeps the deterministic state — iteration counter, regression
7
+ * fingerprints, token budget, success/kill verdict — out of the LLM context.
8
+ *
9
+ * loop.mjs init --url X --brief PATH --max 8 --budget 100000 --state STATE.json
10
+ * loop.mjs record --state STATE.json --eval EVAL.json
11
+ * loop.mjs status --state STATE.json # → JSON: continue|success|killed|out_of_budget
12
+ * loop.mjs commit-fix --state STATE.json --file PATH --slug SLUG
13
+ * loop.mjs report --state STATE.json # → final markdown to stdout
14
+ *
15
+ * Eval JSON contract (what the vision evaluator returns):
16
+ * {
17
+ * "iteration": 1,
18
+ * "viewport_results": [{ viewport, scores, top_issues }],
19
+ * "aggregate_scores": { typography, color, spatial, layout, shadow, motion, microcopy, container },
20
+ * "top_issues": [{ dim, severity, description, likely_file, fix }],
21
+ * "tokens_used": 14500
22
+ * }
23
+ *
24
+ * State JSON shape (written by this script, read by Claude):
25
+ * {
26
+ * "url", "brief_path", "max_iterations", "token_budget", "tokens_used",
27
+ * "iteration", "verdict", "iterations": [...], "fingerprints": {...}, "fixes": [...]
28
+ * }
29
+ */
30
+
31
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
32
+ import { dirname } from "node:path";
33
+ import { argv, exit } from "node:process";
34
+ import { spawnSync } from "node:child_process";
35
+
36
+ const DIMS = ["typography", "color", "spatial", "layout", "shadow", "motion", "microcopy", "container"];
37
+
38
+ // ── helpers ──────────────────────────────────────────────────────────────
39
+ function loadState(p) {
40
+ if (!existsSync(p)) { console.error(`state file not found: ${p}`); exit(2); }
41
+ try { return JSON.parse(readFileSync(p, "utf8")); }
42
+ catch (e) { console.error(`state file unreadable: ${e.message}`); exit(2); }
43
+ }
44
+ function saveState(p, s) {
45
+ if (!existsSync(dirname(p))) mkdirSync(dirname(p), { recursive: true });
46
+ writeFileSync(p, JSON.stringify(s, null, 2) + "\n");
47
+ }
48
+ function flag(name, def = null) {
49
+ const i = argv.indexOf(name);
50
+ if (i < 0) return def;
51
+ return argv[i + 1] || def;
52
+ }
53
+ function flagInt(name, def) { const v = flag(name); return v == null ? def : parseInt(v, 10); }
54
+
55
+ function fingerprintIssue(issue) {
56
+ const dim = String(issue.dim || "").toLowerCase().replace(/\W+/g, "_");
57
+ const file = String(issue.likely_file || issue.file || "_unknown").split("/").pop().replace(/\W+/g, "_");
58
+ const kw = String(issue.description || "").toLowerCase().replace(/[^a-z0-9]+/g, "_").slice(0, 32);
59
+ return `${dim}__${file}__${kw}`;
60
+ }
61
+
62
+ // ── commands ─────────────────────────────────────────────────────────────
63
+ function cmdInit() {
64
+ const statePath = flag("--state");
65
+ if (!statePath) { console.error("--state required"); exit(2); }
66
+ const url = flag("--url");
67
+ if (!url) { console.error("--url required"); exit(2); }
68
+ const max = flagInt("--max", 8);
69
+ const budget = flagInt("--budget", 100000);
70
+ const state = {
71
+ url,
72
+ brief_path: flag("--brief", null),
73
+ reference_path: flag("--ref", null),
74
+ max_iterations: max,
75
+ token_budget: budget,
76
+ tokens_used: 0,
77
+ iteration: 0,
78
+ verdict: "pending",
79
+ iterations: [],
80
+ fingerprints: {},
81
+ fixes: [],
82
+ started_at: new Date().toISOString(),
83
+ };
84
+ saveState(statePath, state);
85
+ console.log(JSON.stringify({ ok: true, state_path: statePath, state }, null, 2));
86
+ }
87
+
88
+ function cmdRecord() {
89
+ const statePath = flag("--state");
90
+ const evalPath = flag("--eval");
91
+ if (!statePath || !evalPath) { console.error("--state and --eval required"); exit(2); }
92
+ const state = loadState(statePath);
93
+ const ev = JSON.parse(readFileSync(evalPath, "utf8"));
94
+
95
+ state.iteration = (ev.iteration ?? state.iteration + 1);
96
+ const scores = ev.aggregate_scores || {};
97
+ const failingDims = DIMS.filter((d) => (scores[d] ?? 3) < 3);
98
+ const total = DIMS.reduce((a, d) => a + (scores[d] ?? 3), 0);
99
+ const pass = failingDims.length === 0;
100
+ const issues = Array.isArray(ev.top_issues) ? ev.top_issues.slice(0, 3) : [];
101
+
102
+ // regression tracking — update fingerprints
103
+ const seenThisIter = new Set();
104
+ for (const it of issues) {
105
+ const fp = fingerprintIssue(it);
106
+ seenThisIter.add(fp);
107
+ if (!state.fingerprints[fp]) state.fingerprints[fp] = { iterations: [], description: it.description, dim: it.dim };
108
+ if (!state.fingerprints[fp].iterations.includes(state.iteration)) {
109
+ state.fingerprints[fp].iterations.push(state.iteration);
110
+ }
111
+ }
112
+ // Reset fingerprints that did NOT recur this iteration so we count CONSECUTIVE recurrences.
113
+ // The kill criterion is "same issue recurred 3x consecutively" — non-consecutive doesn't kill.
114
+ for (const fp of Object.keys(state.fingerprints)) {
115
+ if (!seenThisIter.has(fp)) {
116
+ const fpRec = state.fingerprints[fp];
117
+ const last = fpRec.iterations[fpRec.iterations.length - 1];
118
+ if (last !== state.iteration && last !== state.iteration - 1) {
119
+ // gap → reset run length but keep history
120
+ fpRec.iterations = [];
121
+ }
122
+ }
123
+ }
124
+
125
+ const regression = Object.entries(state.fingerprints).find(([, v]) => {
126
+ const its = v.iterations.slice(-3);
127
+ return its.length >= 3 && its.every((n, i, arr) => i === 0 || n === arr[i - 1] + 1);
128
+ });
129
+
130
+ state.tokens_used += parseInt(ev.tokens_used || 0, 10) || 0;
131
+
132
+ state.iterations.push({
133
+ iteration: state.iteration,
134
+ scores: { ...scores },
135
+ aggregate: total,
136
+ pass,
137
+ failing_dims: failingDims,
138
+ top_issues: issues,
139
+ tokens_used: ev.tokens_used || 0,
140
+ timestamp: new Date().toISOString(),
141
+ });
142
+
143
+ // verdict logic — order matters
144
+ if (pass) state.verdict = "success";
145
+ else if (regression) {
146
+ state.verdict = "killed_regression";
147
+ state.kill_reason = `LOOP_REGRESSION_DETECTED: ${regression[0]} recurred in 3 consecutive iterations`;
148
+ state.kill_fingerprint = regression[0];
149
+ } else if (state.tokens_used > state.token_budget) {
150
+ state.verdict = "out_of_budget";
151
+ state.kill_reason = `OUT_OF_BUDGET: ${state.tokens_used} > ${state.token_budget}`;
152
+ } else if (state.iteration >= state.max_iterations) {
153
+ state.verdict = "killed_max_iterations";
154
+ state.kill_reason = `MAX_ITERATIONS_REACHED: ${state.iteration} >= ${state.max_iterations}`;
155
+ } else {
156
+ state.verdict = "continue";
157
+ }
158
+
159
+ saveState(statePath, state);
160
+ console.log(JSON.stringify({
161
+ iteration: state.iteration,
162
+ pass,
163
+ verdict: state.verdict,
164
+ failing_dims: failingDims,
165
+ aggregate: total,
166
+ tokens_used: state.tokens_used,
167
+ token_budget: state.token_budget,
168
+ issues_to_fix: state.verdict === "continue" ? issues : [],
169
+ kill_reason: state.kill_reason || null,
170
+ }, null, 2));
171
+ // exit codes: 0 = success, 1 = continue, 2 = error, 3 = killed
172
+ if (state.verdict === "success") exit(0);
173
+ if (state.verdict === "continue") exit(1);
174
+ exit(3);
175
+ }
176
+
177
+ function cmdStatus() {
178
+ const statePath = flag("--state");
179
+ if (!statePath) { console.error("--state required"); exit(2); }
180
+ const state = loadState(statePath);
181
+ console.log(JSON.stringify({
182
+ verdict: state.verdict,
183
+ iteration: state.iteration,
184
+ max_iterations: state.max_iterations,
185
+ tokens_used: state.tokens_used,
186
+ token_budget: state.token_budget,
187
+ fixes_applied: state.fixes.length,
188
+ last_aggregate: state.iterations.length ? state.iterations[state.iterations.length - 1].aggregate : null,
189
+ }, null, 2));
190
+ }
191
+
192
+ function cmdCommitFix() {
193
+ const statePath = flag("--state");
194
+ const file = flag("--file");
195
+ const slug = flag("--slug", "fix");
196
+ if (!statePath || !file) { console.error("--state and --file required"); exit(2); }
197
+ const state = loadState(statePath);
198
+
199
+ // slop-detect gate — block on critical findings
200
+ const slopBin = process.env.SLOP_DETECT_BIN || "node";
201
+ const slopScript = process.env.SLOP_DETECT_SCRIPT || `${process.env.HOME}/.claude/bin/slop-detect.mjs`;
202
+ if (existsSync(slopScript)) {
203
+ const r = spawnSync(slopBin, [slopScript, file], { encoding: "utf8" });
204
+ if (r.status === 1) {
205
+ console.log(JSON.stringify({ ok: false, gate: "slop-detect", file, output: r.stdout }, null, 2));
206
+ exit(2);
207
+ }
208
+ }
209
+
210
+ // Stage + commit
211
+ const safeSlug = String(slug).toLowerCase().replace(/[^a-z0-9-]+/g, "-").slice(0, 48);
212
+ const msg = `qpl-${state.iteration}: ${safeSlug}`;
213
+ const add = spawnSync("git", ["add", file], { encoding: "utf8" });
214
+ if (add.status !== 0) { console.error(add.stderr); exit(2); }
215
+ const commit = spawnSync("git", ["commit", "-m", msg], { encoding: "utf8" });
216
+ if (commit.status !== 0) {
217
+ // empty diff → nothing to commit; not fatal
218
+ if (/nothing to commit/i.test(commit.stdout + commit.stderr)) {
219
+ console.log(JSON.stringify({ ok: true, committed: false, reason: "no-op" }));
220
+ return;
221
+ }
222
+ console.error(commit.stderr); exit(2);
223
+ }
224
+ const sha = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
225
+ state.fixes.push({ iteration: state.iteration, file, slug: safeSlug, sha, ts: new Date().toISOString() });
226
+ saveState(statePath, state);
227
+ console.log(JSON.stringify({ ok: true, committed: true, sha, msg }, null, 2));
228
+ }
229
+
230
+ function cmdReport() {
231
+ const statePath = flag("--state");
232
+ if (!statePath) { console.error("--state required"); exit(2); }
233
+ const state = loadState(statePath);
234
+ const lines = [];
235
+ lines.push(`# Visual-Polish Loop Report`);
236
+ lines.push("");
237
+ lines.push(`- **URL:** ${state.url}`);
238
+ lines.push(`- **Brief:** ${state.brief_path || "_(none)_"}`);
239
+ lines.push(`- **Started:** ${state.started_at}`);
240
+ lines.push(`- **Final verdict:** ${state.verdict.toUpperCase()}${state.kill_reason ? ` — ${state.kill_reason}` : ""}`);
241
+ lines.push(`- **Iterations:** ${state.iteration} / ${state.max_iterations}`);
242
+ lines.push(`- **Tokens used:** ${state.tokens_used} / ${state.token_budget}`);
243
+ lines.push(`- **Fixes committed:** ${state.fixes.length}`);
244
+ lines.push("");
245
+ lines.push("## Iteration log");
246
+ for (const it of state.iterations) {
247
+ lines.push("");
248
+ lines.push(`### Iteration ${it.iteration}`);
249
+ const dims = DIMS.map((d) => `${d.slice(0, 4)}=${it.scores[d] ?? "?"}`).join(" ");
250
+ lines.push(`- Scores: ${dims}`);
251
+ lines.push(`- Aggregate: ${it.aggregate}/40 (avg ${(it.aggregate / 8).toFixed(2)})`);
252
+ lines.push(`- Pass: ${it.pass ? "YES" : "NO"} ${it.failing_dims.length ? `(failing: ${it.failing_dims.join(", ")})` : ""}`);
253
+ if (it.top_issues && it.top_issues.length) {
254
+ lines.push(`- Top issues:`);
255
+ for (const iss of it.top_issues) {
256
+ lines.push(` - **${iss.dim}** [${iss.severity}] ${iss.description} → ${iss.likely_file || "_(file?)_"}`);
257
+ }
258
+ }
259
+ }
260
+ if (state.fixes.length) {
261
+ lines.push("");
262
+ lines.push("## Fix commits (revertable)");
263
+ for (const f of state.fixes) lines.push(`- \`${f.sha}\` qpl-${f.iteration}: ${f.slug} — ${f.file}`);
264
+ }
265
+ if (Object.keys(state.fingerprints).length) {
266
+ lines.push("");
267
+ lines.push("## Issue fingerprints (regression tracker)");
268
+ for (const [fp, rec] of Object.entries(state.fingerprints)) {
269
+ lines.push(`- \`${fp}\` — iterations ${JSON.stringify(rec.iterations)} — ${rec.description || ""}`);
270
+ }
271
+ }
272
+ console.log(lines.join("\n"));
273
+ }
274
+
275
+ // ── dispatch ─────────────────────────────────────────────────────────────
276
+ const cmd = argv[2];
277
+ switch (cmd) {
278
+ case "init": cmdInit(); break;
279
+ case "record": cmdRecord(); break;
280
+ case "status": cmdStatus(); break;
281
+ case "commit-fix": cmdCommitFix(); break;
282
+ case "report": cmdReport(); break;
283
+ case "--help":
284
+ case "-h":
285
+ case undefined:
286
+ console.log(`loop.mjs — orchestrator for /qualia-polish-loop
287
+
288
+ Commands:
289
+ init --state PATH --url URL [--brief PATH] [--ref PATH] [--max 8] [--budget 100000]
290
+ record --state PATH --eval PATH
291
+ status --state PATH
292
+ commit-fix --state PATH --file PATH --slug TEXT
293
+ report --state PATH > report.md
294
+
295
+ Exit codes (record):
296
+ 0 = success (all dims >= 3) 1 = continue (more iterations needed)
297
+ 2 = invocation error 3 = killed (regression / budget / max)`);
298
+ exit(cmd ? 0 : 2);
299
+ default:
300
+ console.error(`unknown command: ${cmd}`);
301
+ exit(2);
302
+ }