qualia-framework 4.4.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 (70) hide show
  1. package/AGENTS.md +24 -0
  2. package/CLAUDE.md +12 -63
  3. package/README.md +24 -18
  4. package/agents/builder.md +13 -33
  5. package/agents/plan-checker.md +18 -0
  6. package/agents/planner.md +17 -0
  7. package/agents/verifier.md +70 -0
  8. package/agents/visual-evaluator.md +132 -0
  9. package/bin/cli.js +64 -23
  10. package/bin/install.js +375 -29
  11. package/bin/qualia-ui.js +208 -1
  12. package/bin/slop-detect.mjs +362 -0
  13. package/bin/state.js +218 -2
  14. package/docs/erp-contract.md +5 -0
  15. package/docs/install-redesign-builder-prompt.md +290 -0
  16. package/docs/install-redesign-pilot.md +234 -0
  17. package/docs/playwright-loop-builder-prompt.md +185 -0
  18. package/docs/playwright-loop-design-notes.md +108 -0
  19. package/docs/playwright-loop-pilot-results.md +170 -0
  20. package/docs/playwright-loop-review-2026-05-03.md +65 -0
  21. package/docs/playwright-loop-tester-prompt.md +213 -0
  22. package/docs/reviews/matt-pocock-skills-analysis.md +300 -0
  23. package/guide.md +9 -5
  24. package/hooks/env-empty-guard.js +74 -0
  25. package/hooks/pre-compact.js +19 -9
  26. package/hooks/pre-deploy-gate.js +8 -2
  27. package/hooks/pre-push.js +26 -12
  28. package/hooks/supabase-destructive-guard.js +62 -0
  29. package/hooks/vercel-account-guard.js +91 -0
  30. package/package.json +2 -1
  31. package/rules/design-brand.md +114 -0
  32. package/rules/design-laws.md +148 -0
  33. package/rules/design-product.md +114 -0
  34. package/rules/design-rubric.md +157 -0
  35. package/rules/grounding.md +4 -0
  36. package/skills/qualia-build/SKILL.md +40 -46
  37. package/skills/qualia-discuss/SKILL.md +51 -68
  38. package/skills/qualia-handoff/SKILL.md +1 -0
  39. package/skills/qualia-issues/SKILL.md +151 -0
  40. package/skills/qualia-map/SKILL.md +78 -35
  41. package/skills/qualia-new/REFERENCE.md +139 -0
  42. package/skills/qualia-new/SKILL.md +85 -124
  43. package/skills/qualia-optimize/REFERENCE.md +202 -0
  44. package/skills/qualia-optimize/SKILL.md +72 -237
  45. package/skills/qualia-plan/SKILL.md +58 -65
  46. package/skills/qualia-polish/SKILL.md +180 -136
  47. package/skills/qualia-polish-loop/REFERENCE.md +265 -0
  48. package/skills/qualia-polish-loop/SKILL.md +201 -0
  49. package/skills/qualia-polish-loop/fixtures/broken.html +117 -0
  50. package/skills/qualia-polish-loop/fixtures/clean.html +196 -0
  51. package/skills/qualia-polish-loop/scripts/loop.mjs +302 -0
  52. package/skills/qualia-polish-loop/scripts/playwright-capture.mjs +197 -0
  53. package/skills/qualia-polish-loop/scripts/score.mjs +176 -0
  54. package/skills/qualia-report/SKILL.md +141 -180
  55. package/skills/qualia-research/SKILL.md +28 -33
  56. package/skills/qualia-road/SKILL.md +103 -0
  57. package/skills/qualia-ship/SKILL.md +1 -0
  58. package/skills/qualia-task/SKILL.md +1 -1
  59. package/skills/qualia-test/SKILL.md +50 -2
  60. package/skills/qualia-triage/SKILL.md +152 -0
  61. package/skills/qualia-verify/SKILL.md +63 -104
  62. package/skills/qualia-zoom/SKILL.md +51 -0
  63. package/skills/zoho-workflow/SKILL.md +64 -0
  64. package/templates/CONTEXT.md +36 -0
  65. package/templates/DESIGN.md +229 -435
  66. package/templates/PRODUCT.md +95 -0
  67. package/templates/decisions/ADR-template.md +30 -0
  68. package/tests/bin.test.sh +451 -7
  69. package/tests/state.test.sh +58 -0
  70. package/skills/qualia-design/SKILL.md +0 -169
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * playwright-capture.mjs — screenshot capture for the visual-polish loop.
4
+ *
5
+ * Prefers Playwright when the package is import-resolvable (project has it as a
6
+ * dep, or the user ran `npm i -g playwright`). Falls back to driving a cached
7
+ * Chromium / Google Chrome binary directly via `--headless=new --screenshot`.
8
+ * Either path produces equivalent PNG output for the vision evaluator.
9
+ *
10
+ * Usage:
11
+ * node playwright-capture.mjs --url http://localhost:3000 --out /tmp/qpl-1
12
+ * node playwright-capture.mjs --url http://localhost:3000 --viewports 375,768,1440 --out /tmp/qpl-1 --wait 1500
13
+ *
14
+ * Output (one PNG per viewport, in --out):
15
+ * mobile-375.png tablet-768.png desktop-1440.png
16
+ *
17
+ * Stdout: a JSON envelope with per-capture status. Exit 0 on full success,
18
+ * 1 if any viewport failed, 2 on invocation error.
19
+ */
20
+
21
+ import { existsSync, mkdirSync, statSync, readdirSync } from "node:fs";
22
+ import { join } from "node:path";
23
+ import { spawnSync } from "node:child_process";
24
+ import { argv, exit } from "node:process";
25
+ import { homedir } from "node:os";
26
+
27
+ // ── Arg parsing ──────────────────────────────────────────────────────────
28
+ function parseArgs() {
29
+ const args = { url: null, out: null, viewports: [375, 768, 1440], wait: 1500 };
30
+ for (let i = 2; i < argv.length; i++) {
31
+ const a = argv[i];
32
+ if (a === "--url" && argv[i + 1]) args.url = argv[++i];
33
+ else if (a === "--out" && argv[i + 1]) args.out = argv[++i];
34
+ else if (a === "--viewports" && argv[i + 1]) {
35
+ args.viewports = argv[++i].split(",").map((s) => parseInt(s, 10)).filter((n) => Number.isFinite(n) && n > 0);
36
+ } else if (a === "--wait" && argv[i + 1]) args.wait = parseInt(argv[++i], 10);
37
+ else if (a === "--help" || a === "-h") {
38
+ console.log(`playwright-capture.mjs — Screenshot capture for /qualia-polish-loop
39
+
40
+ Usage:
41
+ node playwright-capture.mjs --url <url> --out <dir> [--viewports 375,768,1440] [--wait 1500]
42
+
43
+ Backend selection (auto):
44
+ 1. Playwright — import('playwright') if installed
45
+ 2. Chromium — ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome
46
+ 3. Google Chrome — google-chrome on PATH
47
+ 4. Chromium — chromium on PATH`);
48
+ exit(0);
49
+ }
50
+ }
51
+ if (!args.url) { console.error("--url is required"); exit(2); }
52
+ if (!args.out) { console.error("--out is required"); exit(2); }
53
+ return args;
54
+ }
55
+
56
+ // ── Viewport name mapping ────────────────────────────────────────────────
57
+ function viewportName(width) {
58
+ if (width <= 480) return "mobile";
59
+ if (width <= 900) return "tablet";
60
+ return "desktop";
61
+ }
62
+ function viewportHeight(width) {
63
+ if (width <= 480) return 812;
64
+ if (width <= 900) return 1024;
65
+ return 900;
66
+ }
67
+
68
+ // ── Backend: Playwright (preferred when available) ──────────────────────
69
+ async function captureViaPlaywright(args) {
70
+ let chromium;
71
+ try {
72
+ ({ chromium } = await import("playwright"));
73
+ } catch {
74
+ try { ({ chromium } = await import("playwright-core")); } catch { return null; }
75
+ }
76
+ const results = [];
77
+ let browser = null;
78
+ try {
79
+ browser = await chromium.launch({ headless: true });
80
+ for (const width of args.viewports) {
81
+ const name = viewportName(width);
82
+ const height = viewportHeight(width);
83
+ const file = join(args.out, `${name}-${width}.png`);
84
+ try {
85
+ const ctx = await browser.newContext({ viewport: { width, height }, deviceScaleFactor: 1 });
86
+ const page = await ctx.newPage();
87
+ await page.goto(args.url, { waitUntil: "networkidle", timeout: 30000 });
88
+ if (args.wait > 0) await page.waitForTimeout(args.wait);
89
+ await page.screenshot({ path: file, fullPage: false });
90
+ await ctx.close();
91
+ results.push({ viewport: name, width, height, file, ok: true, backend: "playwright" });
92
+ } catch (err) {
93
+ results.push({ viewport: name, width, height, file, ok: false, backend: "playwright", error: err.message });
94
+ }
95
+ }
96
+ } finally {
97
+ if (browser) await browser.close();
98
+ }
99
+ return results;
100
+ }
101
+
102
+ // ── Backend: Chrome/Chromium headless via spawn ─────────────────────────
103
+ function findChromeBinary() {
104
+ // 1. Playwright cached chromium (newest first)
105
+ try {
106
+ const pwCache = join(homedir(), ".cache", "ms-playwright");
107
+ if (existsSync(pwCache)) {
108
+ const dirs = readdirSync(pwCache)
109
+ .filter((d) => d.startsWith("chromium-"))
110
+ .sort((a, b) => parseInt(b.split("-")[1], 10) - parseInt(a.split("-")[1], 10));
111
+ for (const d of dirs) {
112
+ for (const sub of ["chrome-linux64/chrome", "chrome-linux/chrome", "chrome-mac/Chromium.app/Contents/MacOS/Chromium", "chrome-win/chrome.exe"]) {
113
+ const p = join(pwCache, d, sub);
114
+ if (existsSync(p)) return p;
115
+ }
116
+ }
117
+ }
118
+ } catch {}
119
+
120
+ // 2. PATH lookups
121
+ for (const cmd of ["google-chrome", "chromium", "chromium-browser", "chrome"]) {
122
+ const r = spawnSync("which", [cmd], { encoding: "utf8" });
123
+ if (r.status === 0 && r.stdout) return r.stdout.trim();
124
+ }
125
+ return null;
126
+ }
127
+
128
+ function captureViaChromeBinary(args, binary) {
129
+ const results = [];
130
+ for (const width of args.viewports) {
131
+ const name = viewportName(width);
132
+ const height = viewportHeight(width);
133
+ const file = join(args.out, `${name}-${width}.png`);
134
+ const flags = [
135
+ "--headless=new",
136
+ "--no-sandbox",
137
+ "--disable-gpu",
138
+ "--disable-dev-shm-usage",
139
+ "--hide-scrollbars",
140
+ `--window-size=${width},${height}`,
141
+ `--screenshot=${file}`,
142
+ `--virtual-time-budget=${Math.max(args.wait + 1000, 3000)}`,
143
+ args.url,
144
+ ];
145
+ const r = spawnSync(binary, flags, { encoding: "utf8", timeout: 30000 });
146
+ let ok = r.status === 0 && existsSync(file);
147
+ let size = 0;
148
+ if (ok) {
149
+ try { size = statSync(file).size; } catch { ok = false; }
150
+ if (size < 100) ok = false; // empty/zero PNG → treat as failure
151
+ }
152
+ results.push({
153
+ viewport: name, width, height, file, ok,
154
+ backend: "chrome-binary", binary,
155
+ ...(ok ? {} : { error: r.stderr ? r.stderr.split("\n").slice(0, 3).join(" / ") : `exit ${r.status}` }),
156
+ });
157
+ }
158
+ return results;
159
+ }
160
+
161
+ // ── Main ─────────────────────────────────────────────────────────────────
162
+ async function main() {
163
+ const args = parseArgs();
164
+ if (!existsSync(args.out)) mkdirSync(args.out, { recursive: true });
165
+
166
+ let captures = await captureViaPlaywright(args);
167
+ let backendUsed = "playwright";
168
+ if (captures === null) {
169
+ const bin = findChromeBinary();
170
+ if (!bin) {
171
+ console.error(JSON.stringify({
172
+ ok: false,
173
+ error: "no_browser_backend",
174
+ hint: "Install Playwright (`npm i playwright && npx playwright install chromium`) or ensure google-chrome / chromium is on PATH.",
175
+ }, null, 2));
176
+ exit(2);
177
+ }
178
+ captures = captureViaChromeBinary(args, bin);
179
+ backendUsed = "chrome-binary";
180
+ }
181
+
182
+ const failed = captures.filter((c) => !c.ok).length;
183
+ console.log(JSON.stringify({
184
+ url: args.url,
185
+ output_dir: args.out,
186
+ backend: backendUsed,
187
+ captures,
188
+ total: captures.length,
189
+ failed,
190
+ }, null, 2));
191
+ exit(failed > 0 ? 1 : 0);
192
+ }
193
+
194
+ main().catch((e) => {
195
+ console.error(JSON.stringify({ ok: false, error: e.message, stack: e.stack }, null, 2));
196
+ exit(2);
197
+ });
@@ -0,0 +1,176 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * score.mjs -- Qualia visual-polish loop scoring utility.
4
+ *
5
+ * Takes a JSON object with 8 dimension scores and computes pass/fail
6
+ * per the design rubric formula from rules/design-rubric.md.
7
+ *
8
+ * Usage:
9
+ * echo '{"typography":3,"color":2,"spatial":3,"layout":3,"shadow":3,"motion":3,"microcopy":3,"container":3}' | node score.mjs
10
+ * node score.mjs --file scores.json
11
+ * node score.mjs --scores '{"typography":4,"color":3,...}'
12
+ *
13
+ * Output (JSON):
14
+ * { total, avg, pass, failing: [{dim, score}], verdict }
15
+ *
16
+ * Exit codes:
17
+ * 0 all dimensions >= 3 (pass)
18
+ * 1 one or more dimensions < 3 (fail)
19
+ * 2 invocation error
20
+ */
21
+
22
+ import { readFileSync } from "node:fs";
23
+ import { argv, stdin, exit } from "node:process";
24
+
25
+ const DIMENSIONS = [
26
+ "typography",
27
+ "color",
28
+ "spatial",
29
+ "layout",
30
+ "shadow",
31
+ "motion",
32
+ "microcopy",
33
+ "container",
34
+ ];
35
+
36
+ const DIMENSION_ALIASES = {
37
+ "color cohesion": "color",
38
+ "color_cohesion": "color",
39
+ "spatial rhythm": "spatial",
40
+ "spatial_rhythm": "spatial",
41
+ "layout originality": "layout",
42
+ "layout_originality": "layout",
43
+ "shadow & depth": "shadow",
44
+ "shadow_depth": "shadow",
45
+ "shadow & depth hierarchy": "shadow",
46
+ "motion intent": "motion",
47
+ "motion_intent": "motion",
48
+ "microcopy specificity": "microcopy",
49
+ "microcopy_specificity": "microcopy",
50
+ "container depth": "container",
51
+ "container_depth": "container",
52
+ "container depth & nesting": "container",
53
+ };
54
+
55
+ function normalizeKey(key) {
56
+ const lower = key.toLowerCase().trim();
57
+ return DIMENSION_ALIASES[lower] || lower;
58
+ }
59
+
60
+ function parseScores(raw) {
61
+ if (typeof raw === "string") {
62
+ try {
63
+ raw = JSON.parse(raw);
64
+ } catch (e) {
65
+ console.error(`Invalid JSON: ${e.message}`);
66
+ exit(2);
67
+ }
68
+ }
69
+
70
+ const scores = {};
71
+ for (const [key, value] of Object.entries(raw)) {
72
+ const normalized = normalizeKey(key);
73
+ if (DIMENSIONS.includes(normalized)) {
74
+ const num = parseInt(value, 10);
75
+ if (num < 1 || num > 5 || isNaN(num)) {
76
+ console.error(`Invalid score for ${key}: ${value} (must be 1-5)`);
77
+ exit(2);
78
+ }
79
+ scores[normalized] = num;
80
+ }
81
+ }
82
+
83
+ // Validate all dimensions present
84
+ const missing = DIMENSIONS.filter((d) => !(d in scores));
85
+ if (missing.length > 0) {
86
+ console.error(`Missing dimensions: ${missing.join(", ")}`);
87
+ exit(2);
88
+ }
89
+
90
+ return scores;
91
+ }
92
+
93
+ function computeResult(scores) {
94
+ const dims = DIMENSIONS.map((d) => scores[d]);
95
+ const total = dims.reduce((a, b) => a + b, 0);
96
+ const avg = +(total / dims.length).toFixed(2);
97
+ const failing = DIMENSIONS.filter((d) => scores[d] < 3).map((d) => ({
98
+ dim: d,
99
+ score: scores[d],
100
+ }));
101
+ const pass = failing.length === 0;
102
+
103
+ let verdict;
104
+ if (pass) {
105
+ verdict = "SUCCESS";
106
+ } else if (failing.some((f) => f.score === 1)) {
107
+ verdict = "CRITICAL_FAIL";
108
+ } else {
109
+ verdict = "FAIL";
110
+ }
111
+
112
+ return {
113
+ scores,
114
+ total,
115
+ max: 40,
116
+ avg,
117
+ pass,
118
+ failing,
119
+ verdict,
120
+ };
121
+ }
122
+
123
+ // -- CLI entry point --
124
+
125
+ async function main() {
126
+ let input = null;
127
+
128
+ // Parse args
129
+ for (let i = 2; i < argv.length; i++) {
130
+ if (argv[i] === "--file" && argv[i + 1]) {
131
+ try {
132
+ input = readFileSync(argv[i + 1], "utf8");
133
+ } catch (e) {
134
+ console.error(`Cannot read file: ${e.message}`);
135
+ exit(2);
136
+ }
137
+ i++;
138
+ } else if (argv[i] === "--scores" && argv[i + 1]) {
139
+ input = argv[i + 1];
140
+ i++;
141
+ } else if (argv[i] === "--help" || argv[i] === "-h") {
142
+ console.log(`score.mjs -- Qualia visual-polish loop scoring utility
143
+
144
+ Usage:
145
+ echo '{"typography":3,...}' | node score.mjs
146
+ node score.mjs --file scores.json
147
+ node score.mjs --scores '{"typography":4,...}'
148
+
149
+ Dimensions: ${DIMENSIONS.join(", ")}
150
+ Each scored 1-5. All must be >= 3 to pass.`);
151
+ exit(0);
152
+ }
153
+ }
154
+
155
+ // Read from stdin if no explicit input
156
+ if (!input) {
157
+ const chunks = [];
158
+ for await (const chunk of stdin) {
159
+ chunks.push(chunk);
160
+ }
161
+ input = Buffer.concat(chunks).toString("utf8").trim();
162
+ }
163
+
164
+ if (!input) {
165
+ console.error("No input provided. Use --file, --scores, or pipe JSON to stdin.");
166
+ exit(2);
167
+ }
168
+
169
+ const scores = parseScores(input);
170
+ const result = computeResult(scores);
171
+
172
+ console.log(JSON.stringify(result, null, 2));
173
+ exit(result.pass ? 0 : 1);
174
+ }
175
+
176
+ main();