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
package/bin/cli.js CHANGED
@@ -160,10 +160,15 @@ const QUALIA_AGENT_FILES = [
160
160
  ];
161
161
 
162
162
  // 3 Qualia bin scripts.
163
- const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js", "knowledge.js", "knowledge-flush.js", "plan-contract.js", "agent-runs.js"];
164
-
165
- // 6 Qualia rules.
166
- const QUALIA_RULE_FILES = ["security.md", "frontend.md", "design-reference.md", "deployment.md", "infrastructure.md", "grounding.md"];
163
+ const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js", "knowledge.js", "knowledge-flush.js", "plan-contract.js", "agent-runs.js", "slop-detect.mjs"];
164
+
165
+ // Qualia rules — security, deployment, infra, grounding, plus the v4.5.0 design substrate.
166
+ // frontend.md and design-reference.md are kept for backward compat; new projects use design-laws/brand/product/rubric.
167
+ const QUALIA_RULE_FILES = [
168
+ "security.md", "deployment.md", "infrastructure.md", "grounding.md",
169
+ "frontend.md", "design-reference.md",
170
+ "design-laws.md", "design-brand.md", "design-product.md", "design-rubric.md",
171
+ ];
167
172
 
168
173
  function promptYesNo(question, defaultYes) {
169
174
  return new Promise((resolve) => {
@@ -819,7 +824,7 @@ function cmdAnalytics() {
819
824
  // validity, and endpoint health. Uses a distinct dry_run=true flag in the
820
825
  // payload so receivers can filter these out of real report views.
821
826
 
822
- function cmdErpPing() {
827
+ async function cmdErpPing() {
823
828
  banner();
824
829
  console.log("");
825
830
 
@@ -882,22 +887,45 @@ function cmdErpPing() {
882
887
  dry_run: true,
883
888
  });
884
889
 
890
+ // v5.0 — use Node's native https.request instead of `curl -H "Authorization: Bearer $KEY"`.
891
+ // Reason: passing the bearer token as a curl CLI argument exposes it via /proc/<pid>/cmdline,
892
+ // readable by any local process during the curl invocation. https.request keeps the auth
893
+ // header in-process — never visible to other users.
894
+ const httpsLib = require("https");
895
+ const httpLib = require("http");
896
+ const urlLib = require("url");
897
+ const u = urlLib.parse(`${erpUrl}/api/v1/reports`);
898
+ const lib = u.protocol === "https:" ? httpsLib : httpLib;
885
899
  const started = Date.now();
886
- const r = spawnSync("curl", [
887
- "-sS", "-X", "POST",
888
- "-H", `Authorization: Bearer ${apiKey}`,
889
- "-H", "Content-Type: application/json",
890
- "-d", payload,
891
- "--max-time", "10",
892
- "-w", "\n__HTTP__%{http_code}",
893
- `${erpUrl}/api/v1/reports`,
894
- ], { encoding: "utf8", timeout: 12000 });
900
+ const { code: httpCode, body, error: reqErr } = await new Promise((resolve) => {
901
+ const req = lib.request({
902
+ method: "POST",
903
+ hostname: u.hostname,
904
+ port: u.port || (u.protocol === "https:" ? 443 : 80),
905
+ path: u.path,
906
+ headers: {
907
+ "Authorization": `Bearer ${apiKey}`,
908
+ "Content-Type": "application/json",
909
+ "Content-Length": Buffer.byteLength(payload),
910
+ },
911
+ timeout: 10000,
912
+ }, (res) => {
913
+ let chunks = "";
914
+ res.setEncoding("utf8");
915
+ res.on("data", (c) => { chunks += c; });
916
+ res.on("end", () => resolve({ code: String(res.statusCode), body: chunks.trim(), error: null }));
917
+ });
918
+ req.on("error", (e) => resolve({ code: "—", body: "", error: e.message }));
919
+ req.on("timeout", () => { req.destroy(new Error("timeout")); });
920
+ req.write(payload);
921
+ req.end();
922
+ });
895
923
 
896
924
  const duration = Date.now() - started;
897
- const raw = (r.stdout || "") + (r.stderr || "");
898
- const httpMatch = raw.match(/__HTTP__(\d+)/);
899
- const httpCode = httpMatch ? httpMatch[1] : "—";
900
- const body = raw.replace(/\n?__HTTP__\d+/, "").trim();
925
+ if (reqErr) {
926
+ console.log(` ${RED}✗${RESET} Network error: ${reqErr}`);
927
+ process.exit(1);
928
+ }
901
929
 
902
930
  console.log(` ${DIM}Response:${RESET} ${WHITE}HTTP ${httpCode}${RESET} ${DIM}(${duration}ms)${RESET}`);
903
931
  if (body) {
@@ -951,16 +979,29 @@ function cmdSetErpKey() {
951
979
  return;
952
980
  }
953
981
 
954
- let key = rawArgs.find((a) => a && !a.startsWith("--")) || "";
955
- if (!key && !process.stdin.isTTY) {
982
+ // v5.0 refuse positional argument for ERP key. Positional args leak into
983
+ // shell history (~/.bash_history, ~/.zsh_history) where any local user with
984
+ // file access can read them. Read from stdin only (piped or env-piped).
985
+ const positional = rawArgs.find((a) => a && !a.startsWith("--"));
986
+ if (positional) {
987
+ console.log(` ${RED}✗${RESET} Refusing to accept ERP key as a positional CLI argument.`);
988
+ console.log(` ${DIM}Reason:${RESET} positional args land in shell history (~/.bash_history, ~/.zsh_history).`);
989
+ console.log(` ${DIM}Safe usage:${RESET} ${TEAL}printf '%s' "\$QUALIA_ERP_KEY" | qualia-framework set-erp-key${RESET}`);
990
+ console.log(` ${DIM}Or piped:${RESET} ${TEAL}cat /tmp/key | qualia-framework set-erp-key${RESET} ${DIM}(then shred /tmp/key)${RESET}`);
991
+ console.log("");
992
+ process.exit(1);
993
+ }
994
+
995
+ let key = "";
996
+ if (!process.stdin.isTTY) {
956
997
  try { key = fs.readFileSync(0, "utf8").trim(); } catch {}
957
998
  }
958
999
 
959
1000
  key = String(key || "").trim();
960
1001
  if (!key) {
961
1002
  console.log(` ${RED}✗${RESET} Missing ERP API key.`);
962
- console.log(` ${DIM}Usage:${RESET} qualia-framework set-erp-key <key>`);
963
- console.log(` ${DIM}Safe shell history option:${RESET} printf '%s' "$QUALIA_ERP_KEY" | qualia-framework set-erp-key`);
1003
+ console.log(` ${DIM}Usage:${RESET} ${TEAL}printf '%s' "\$QUALIA_ERP_KEY" | qualia-framework set-erp-key${RESET}`);
1004
+ console.log(` ${DIM}Or:${RESET} ${TEAL}cat /tmp/key | qualia-framework set-erp-key${RESET} ${DIM}(then shred /tmp/key)${RESET}`);
964
1005
  console.log("");
965
1006
  process.exit(1);
966
1007
  }
@@ -1215,7 +1256,7 @@ function cmdHelp() {
1215
1256
  console.log(` ${TG}/qualia-plan${RESET} Plan a phase`);
1216
1257
  console.log(` ${TG}/qualia-build${RESET} Build it (parallel tasks)`);
1217
1258
  console.log(` ${TG}/qualia-verify${RESET} Verify it works`);
1218
- console.log(` ${TG}/qualia-design${RESET} One-shot design fix`);
1259
+ console.log(` ${TG}/qualia-polish${RESET} Design pass — any scope (component, route, app, redesign)`);
1219
1260
  console.log(` ${TG}/qualia-debug${RESET} Structured debugging`);
1220
1261
  console.log(` ${TG}/qualia-review${RESET} Production audit`);
1221
1262
  console.log(` ${TG}/qualia-ship${RESET} Deploy to production`);
package/bin/install.js CHANGED
@@ -3,10 +3,12 @@
3
3
  const { createInterface } = require("readline");
4
4
  const path = require("path");
5
5
  const fs = require("fs");
6
+ const ui = require("./qualia-ui.js");
6
7
 
7
- // ─── Colors ──────────────────────────────────────────────
8
+ // ─── Colors (kept for legacy log lines; new sections route through qualia-ui) ─
8
9
  const TEAL = "\x1b[38;2;0;206;209m";
9
10
  const DIM = "\x1b[38;2;80;90;100m";
11
+ const DIM2 = "\x1b[38;2;70;80;90m";
10
12
  const GREEN = "\x1b[38;2;52;211;153m";
11
13
  const WHITE = "\x1b[38;2;220;225;230m";
12
14
  const YELLOW = "\x1b[38;2;234;179;8m";
@@ -14,8 +16,17 @@ const RED = "\x1b[38;2;239;68;68m";
14
16
  const RESET = "\x1b[0m";
15
17
 
16
18
  const CLAUDE_DIR = path.join(require("os").homedir(), ".claude");
19
+ const CODEX_DIR = path.join(require("os").homedir(), ".codex");
17
20
  const FRAMEWORK_DIR = path.resolve(__dirname, "..");
18
21
 
22
+ // Target IDs match the menu numbers shown to the user.
23
+ const TARGET_CLAUDE_ONLY = "1";
24
+ const TARGET_CODEX_ONLY = "2";
25
+ const TARGET_BOTH = "3";
26
+
27
+ // Total install timer — set in main(), read by the final summary card.
28
+ const installStart = Date.now();
29
+
19
30
  // ─── Team codes ──────────────────────────────────────────
20
31
  const DEFAULT_TEAM = {
21
32
  "QS-FAWZI-01": {
@@ -64,16 +75,30 @@ const TEAM = loadTeam();
64
75
  let installed = 0;
65
76
  let errors = 0;
66
77
 
78
+ // Per-section timer state. Started by printSection(), read by closeSection().
79
+ let sectionStart = 0;
80
+ let sectionLabel = "";
81
+ let sectionCount = 0;
82
+
67
83
  function log(msg) {
68
84
  console.log(` ${msg}`);
69
85
  }
70
86
  function ok(label) {
71
87
  installed++;
88
+ sectionCount++;
72
89
  log(`${GREEN}✓${RESET} ${label}`);
73
90
  }
74
91
  function warn(label) {
75
92
  errors++;
76
- log(`${YELLOW}✗${RESET} ${label}`);
93
+ log(`${YELLOW}!${RESET} ${label}`);
94
+ }
95
+ // step(text) → handle returned by qualia-ui. Use for slow ops where the
96
+ // "doing → done" lifecycle helps the user trust the install isn't hung.
97
+ function step(text) {
98
+ return ui.step(text);
99
+ }
100
+ function spin(text) {
101
+ return ui.spinner(text);
77
102
  }
78
103
  function copy(src, dest) {
79
104
  const destDir = path.dirname(dest);
@@ -161,23 +186,135 @@ function printHeader() {
161
186
  }
162
187
 
163
188
  function printSection(title) {
189
+ // Close prior section if one was open (shows count + elapsed).
190
+ closeSection();
191
+ sectionStart = Date.now();
192
+ sectionLabel = title;
193
+ sectionCount = 0;
164
194
  console.log("");
165
195
  console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}${title}${RESET}`);
166
- console.log(` ${DIM}${"─".repeat(40)}${RESET}`);
196
+ console.log(` ${DIM2}${"─".repeat(40)}${RESET}`);
197
+ }
198
+
199
+ function closeSection() {
200
+ if (!sectionLabel) return;
201
+ const elapsedMs = Date.now() - sectionStart;
202
+ const elapsed = elapsedMs >= 1000
203
+ ? `${(elapsedMs / 1000).toFixed(1)}s`
204
+ : `${elapsedMs}ms`;
205
+ if (sectionCount > 0) {
206
+ console.log(` ${DIM2}└─${RESET} ${DIM}${sectionCount} ${sectionLabel.toLowerCase()} · ${elapsed}${RESET}`);
207
+ }
208
+ sectionLabel = "";
209
+ sectionCount = 0;
210
+ }
211
+
212
+ // ─── Prompt helpers ──────────────────────────────────────
213
+ // Two prompts (team code, install target) need to coexist with two stdin
214
+ // modes: interactive TTY and piped. The earlier two-readline approach
215
+ // raced 'close' against the second question on piped installs (`echo CODE
216
+ // | install.js` would EOF before the second question could attach a line
217
+ // listener) — the target prompt always saw "" and defaulted to "1" even
218
+ // when the user piped "CODE\n2\n".
219
+ //
220
+ // Fix: in piped mode, pre-buffer all stdin lines synchronously up front and
221
+ // hand them out one by one. In TTY mode, use a shared readline.
222
+ let SHARED_RL = null;
223
+ let PIPED_LINES = null;
224
+ let PIPED_INDEX = 0;
225
+ const IS_INTERACTIVE = !!(process.stdin && process.stdin.isTTY);
226
+
227
+ function getRl() {
228
+ if (!SHARED_RL) {
229
+ SHARED_RL = createInterface({ input: process.stdin, output: process.stdout });
230
+ }
231
+ return SHARED_RL;
232
+ }
233
+ function closeRl() {
234
+ if (SHARED_RL) { try { SHARED_RL.close(); } catch {} SHARED_RL = null; }
235
+ }
236
+
237
+ // Read every available stdin line into an array. Resolves immediately on
238
+ // 'end'. Used only when stdin is piped (legacy `echo ... | install`).
239
+ function bufferStdin() {
240
+ return new Promise((resolve) => {
241
+ let buf = "";
242
+ process.stdin.setEncoding("utf8");
243
+ process.stdin.on("data", (chunk) => { buf += chunk; });
244
+ process.stdin.on("end", () => {
245
+ const lines = buf.split(/\r?\n/);
246
+ // Trim a trailing empty entry from the final newline, but preserve
247
+ // intentional empties (so an empty target line still defaults to "1"
248
+ // rather than swallowing the team-code line).
249
+ if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
250
+ resolve(lines);
251
+ });
252
+ });
253
+ }
254
+
255
+ function nextPipedLine() {
256
+ if (!PIPED_LINES) return "";
257
+ if (PIPED_INDEX >= PIPED_LINES.length) return "";
258
+ return PIPED_LINES[PIPED_INDEX++];
167
259
  }
168
260
 
169
- // ─── Prompt for code ─────────────────────────────────────
170
261
  function askCode() {
171
262
  return new Promise((resolve) => {
172
- const rl = createInterface({ input: process.stdin, output: process.stdout });
263
+ if (!IS_INTERACTIVE) {
264
+ printHeader();
265
+ const line = nextPipedLine();
266
+ // Echo the prompt + answer for log readability.
267
+ process.stdout.write(` ${WHITE}Enter install code:${RESET} ${line}\n`);
268
+ resolve(String(line || "").trim());
269
+ return;
270
+ }
271
+ const rl = getRl();
173
272
  printHeader();
174
273
  rl.question(` ${WHITE}Enter install code:${RESET} `, (answer) => {
175
- rl.close();
176
- resolve(answer.trim());
274
+ resolve(String(answer || "").trim());
177
275
  });
178
276
  });
179
277
  }
180
278
 
279
+ // ─── Prompt for install target (Claude / Codex / Both) ──
280
+ // Backward-compat: a piped install with only the team code (single stdin
281
+ // line) closes stdin before this prompt; we silently default to "1"
282
+ // (Claude only) so existing scripts keep working untouched.
283
+ function askTarget() {
284
+ return new Promise((resolve) => {
285
+ console.log("");
286
+ console.log(` ${WHITE}Where would you like to install Qualia?${RESET}`);
287
+ console.log("");
288
+ console.log(` ${TEAL}[1]${RESET} ${WHITE}Claude Code only${RESET} ${DIM}— recommended, full feature set${RESET}`);
289
+ console.log(` ${TEAL}[2]${RESET} ${WHITE}OpenAI Codex only${RESET} ${DIM}— AGENTS.md (Codex's open standard)${RESET}`);
290
+ console.log(` ${TEAL}[3]${RESET} ${WHITE}Both${RESET} ${DIM}— max compatibility${RESET}`);
291
+ console.log("");
292
+
293
+ const normalize = (val) => {
294
+ const trimmed = String(val || "").trim();
295
+ // Empty or unrecognized → default to Claude only (preserves legacy
296
+ // single-line piped install: `echo CODE | npx qualia-framework install`).
297
+ if (trimmed === TARGET_CODEX_ONLY || trimmed === TARGET_BOTH) return trimmed;
298
+ return TARGET_CLAUDE_ONLY;
299
+ };
300
+
301
+ if (!IS_INTERACTIVE) {
302
+ const line = nextPipedLine();
303
+ process.stdout.write(` ${WHITE}Choice [1]:${RESET} ${line}\n`);
304
+ resolve(normalize(line));
305
+ return;
306
+ }
307
+ const rl = getRl();
308
+ rl.question(` ${WHITE}Choice [1]:${RESET} `, (answer) => resolve(normalize(answer)));
309
+ });
310
+ }
311
+
312
+ function targetLabel(t) {
313
+ if (t === TARGET_CODEX_ONLY) return "Codex";
314
+ if (t === TARGET_BOTH) return "Claude Code · Codex";
315
+ return "Claude Code";
316
+ }
317
+
181
318
  // ─── Resolve team code (tolerates case + O/0 typo in suffix) ─
182
319
  // Accepts "qs-fawzi-01", "QS-FAWZI-01", "QS-FAWZI-O1" (letter O in the
183
320
  // numeric suffix), and returns the canonical key if found, else null.
@@ -196,6 +333,12 @@ function resolveTeamCode(input) {
196
333
 
197
334
  // ─── Main ────────────────────────────────────────────────
198
335
  async function main() {
336
+ // Piped install: drain stdin once up front. Avoids EOF/'close' racing
337
+ // ahead of the second prompt's 'line' listener (the bug v5.0 didn't have
338
+ // because v5.0 only had one prompt).
339
+ if (!IS_INTERACTIVE) {
340
+ PIPED_LINES = await bufferStdin();
341
+ }
199
342
  const rawCode = await askCode();
200
343
  const code = resolveTeamCode(rawCode);
201
344
  const member = code ? TEAM[code] : null;
@@ -211,7 +354,23 @@ async function main() {
211
354
  console.log("");
212
355
  const roleColor = member.role === "OWNER" ? TEAL : GREEN;
213
356
  console.log(` ${GREEN}✓${RESET} ${WHITE}${BOLD}Welcome, ${member.name}${RESET}`);
214
- console.log(` ${DIM} Role:${RESET} ${roleColor}${member.role}${RESET} ${DIM}·${RESET} ${DIM}Target:${RESET} ${WHITE}${CLAUDE_DIR}${RESET}`);
357
+ console.log(` ${DIM} Role:${RESET} ${roleColor}${member.role}${RESET}`);
358
+
359
+ // ─── Ask install target (Claude / Codex / Both) ────────
360
+ const target = await askTarget();
361
+ closeRl();
362
+ const installClaude = target === TARGET_CLAUDE_ONLY || target === TARGET_BOTH;
363
+ const installCodexTarget = target === TARGET_CODEX_ONLY || target === TARGET_BOTH;
364
+
365
+ console.log("");
366
+ console.log(` ${DIM} Target:${RESET} ${WHITE}${targetLabel(target)}${RESET}`);
367
+
368
+ if (!installClaude) {
369
+ // Codex-only path: skip the entire Claude install block. Jump straight
370
+ // to the Codex installer + final summary.
371
+ await installCodex(member, target);
372
+ return;
373
+ }
215
374
 
216
375
  // ─── Skills ──────────────────────────────────────────
217
376
  const skillsDir = path.join(FRAMEWORK_DIR, "skills");
@@ -226,6 +385,21 @@ async function main() {
226
385
  path.join(skillsDir, skill, "SKILL.md"),
227
386
  path.join(CLAUDE_DIR, "skills", skill, "SKILL.md")
228
387
  );
388
+ // Copy REFERENCE.md if the skill has one (progressive-disclosure pattern)
389
+ const refSrc = path.join(skillsDir, skill, "REFERENCE.md");
390
+ if (fs.existsSync(refSrc)) {
391
+ copy(refSrc, path.join(CLAUDE_DIR, "skills", skill, "REFERENCE.md"));
392
+ }
393
+ // v5.1: Copy scripts/ subfolder if present (e.g. qualia-polish-loop ships
394
+ // playwright-capture.mjs, loop.mjs, score.mjs that the skill invokes at
395
+ // runtime). Recursive — preserves nested files. fixtures/ also copied
396
+ // for self-test scenarios.
397
+ for (const sub of ["scripts", "fixtures"]) {
398
+ const subSrc = path.join(skillsDir, skill, sub);
399
+ if (fs.existsSync(subSrc) && fs.statSync(subSrc).isDirectory()) {
400
+ copyTree(subSrc, path.join(CLAUDE_DIR, "skills", skill, sub));
401
+ }
402
+ }
229
403
  ok(skill);
230
404
  } catch (e) {
231
405
  warn(`${skill} — ${e.message}`);
@@ -383,6 +557,17 @@ async function main() {
383
557
  claudeMd = claudeMd.replace("{{ROLE}}", member.role);
384
558
  claudeMd = claudeMd.replace("{{ROLE_DESCRIPTION}}", member.description);
385
559
  const claudeDest = path.join(CLAUDE_DIR, "CLAUDE.md");
560
+ // v5.0: backup existing CLAUDE.md before overwrite. Users may have added
561
+ // personal instructions; without a backup, re-install silently destroys
562
+ // them. .bak files are harmless and easy to clean up.
563
+ if (fs.existsSync(claudeDest)) {
564
+ const existing = fs.readFileSync(claudeDest, "utf8");
565
+ if (existing !== claudeMd) {
566
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
567
+ const bak = `${claudeDest}.bak.${ts}`;
568
+ try { fs.copyFileSync(claudeDest, bak); ok(`Backed up existing CLAUDE.md → ${path.basename(bak)}`); } catch {}
569
+ }
570
+ }
386
571
  fs.writeFileSync(claudeDest, claudeMd, "utf8");
387
572
  ok(`Configured as ${member.role}`);
388
573
  } catch (e) {
@@ -432,6 +617,12 @@ async function main() {
432
617
  path.join(binDest, "agent-runs.js")
433
618
  );
434
619
  ok("agent-runs.js (agent telemetry writer)");
620
+ copy(
621
+ path.join(FRAMEWORK_DIR, "bin", "slop-detect.mjs"),
622
+ path.join(binDest, "slop-detect.mjs")
623
+ );
624
+ fs.chmodSync(path.join(binDest, "slop-detect.mjs"), 0o755);
625
+ ok("slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)");
435
626
  } catch (e) {
436
627
  warn(`scripts — ${e.message}`);
437
628
  }
@@ -681,6 +872,8 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
681
872
  "session-start.js", "auto-update.js", "branch-guard.js", "pre-push.js",
682
873
  "pre-deploy-gate.js", "migration-guard.js", "pre-compact.js",
683
874
  "git-guardrails.js", "stop-session-log.js",
875
+ // v5.0 — insights-driven destructive-op + wrong-account guards
876
+ "vercel-account-guard.js", "env-empty-guard.js", "supabase-destructive-guard.js",
684
877
  ]);
685
878
  const isQualiaHookCmd = (cmd) => {
686
879
  if (typeof cmd !== "string") return false;
@@ -707,6 +900,10 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
707
900
  { type: "command", if: "Bash(git push*)", command: nodeCmd("branch-guard.js"), timeout: 5, statusMessage: "⬢ Checking branch permissions..." },
708
901
  { type: "command", if: "Bash(git push*)", command: nodeCmd("pre-push.js"), timeout: 15, statusMessage: "⬢ Syncing tracking..." },
709
902
  { type: "command", if: "Bash(vercel --prod*)", command: nodeCmd("pre-deploy-gate.js"), timeout: 180, statusMessage: "⬢ Running quality gates..." },
903
+ // v5.0 hooks — insights-driven friction prevention
904
+ { type: "command", if: "Bash(vercel --prod*)|Bash(vercel deploy*)", command: nodeCmd("vercel-account-guard.js"), timeout: 8, statusMessage: "⬢ Verifying Vercel account..." },
905
+ { type: "command", if: "Bash(vercel env*)", command: nodeCmd("env-empty-guard.js"), timeout: 5, statusMessage: "⬢ Checking env value..." },
906
+ { type: "command", if: "Bash(supabase*)|Bash(npx supabase*)", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5, statusMessage: "⬢ Checking Supabase safety..." },
710
907
  ],
711
908
  },
712
909
  {
@@ -767,54 +964,203 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
767
964
  ok("MCP: next-devtools (runtime error visibility for Next.js projects)");
768
965
  }
769
966
 
770
- fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
967
+ // v5.0: backup existing settings.json before overwrite. The merge logic above
968
+ // preserves user fields, but a partial-write or merger bug could destroy MCP
969
+ // configs / custom permissions. Atomic write (tmp + rename) avoids partial
970
+ // writes; the .bak file is the recovery point if the merger ever misbehaves.
971
+ if (fs.existsSync(settingsPath)) {
972
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
973
+ const bak = `${settingsPath}.bak.${ts}`;
974
+ try { fs.copyFileSync(settingsPath, bak); } catch {}
975
+ }
976
+ const settingsTmp = `${settingsPath}.tmp.${process.pid}`;
977
+ fs.writeFileSync(settingsTmp, JSON.stringify(settings, null, 2));
978
+ fs.renameSync(settingsTmp, settingsPath);
771
979
 
772
- ok("Hooks: session-start, auto-update, branch-guard, pre-push, migration-guard, deploy-gate, pre-compact, git-guardrails, stop-session-log");
980
+ ok("Hooks: session-start, auto-update, branch-guard, pre-push, migration-guard, deploy-gate, pre-compact, git-guardrails, stop-session-log, vercel-account-guard, env-empty-guard, supabase-destructive-guard");
773
981
  ok("Status line + spinner configured");
774
982
  ok("Environment variables + permissions");
775
983
 
984
+ // ─── Codex (optional second target) ──────────────────────
985
+ if (installCodexTarget) {
986
+ await installCodex(member, target);
987
+ }
988
+
776
989
  // ─── Summary ───────────────────────────────────────────
990
+ closeSection();
991
+ printSummary({ member, target, claudeInstalled: true });
992
+ }
993
+
994
+ // ─── Final summary card (shared by Claude / Codex / Both paths) ──
995
+ function printSummary({ member, target, claudeInstalled }) {
996
+ const roleColor = member.role === "OWNER" ? TEAL : GREEN;
997
+ const totalMs = Date.now() - installStart;
998
+ const totalSec = totalMs >= 1000
999
+ ? `${(totalMs / 1000).toFixed(1)}s`
1000
+ : `${totalMs}ms`;
1001
+
777
1002
  console.log("");
778
- console.log(` ${DIM}${RULE}${RESET}`);
1003
+ console.log(` ${DIM2}${RULE}${RESET}`);
779
1004
  console.log(` ${TEAL}${BOLD}⬢ INSTALLED${RESET}`);
780
- console.log(` ${DIM}${RULE}${RESET}`);
1005
+ console.log(` ${DIM2}${RULE}${RESET}`);
781
1006
  console.log("");
782
1007
  console.log(` ${WHITE}${BOLD}${member.name}${RESET} ${DIM}·${RESET} ${roleColor}${member.role}${RESET} ${DIM}·${RESET} ${DIM}v${PKG_VERSION}${RESET}`);
783
1008
  console.log("");
784
- const agentCount = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')).length;
785
- const hookCount = fs.readdirSync(hooksSource).length;
786
- const ruleCount = fs.readdirSync(rulesDir).length;
787
- const tmplCount = fs.readdirSync(tmplDir).length;
788
- console.log(` ${DIM}Skills${RESET} ${TEAL}${skills.length}${RESET} ${DIM}Agents${RESET} ${TEAL}${agentCount}${RESET} ${DIM}Hooks${RESET} ${TEAL}${hookCount}${RESET}`);
789
- const installedBinDir = path.join(CLAUDE_DIR, "bin");
790
- const scriptCount = fs.existsSync(installedBinDir)
791
- ? fs.readdirSync(installedBinDir).filter(f => f.endsWith('.js')).length
792
- : 0;
793
- console.log(` ${DIM}Rules${RESET} ${TEAL}${ruleCount}${RESET} ${DIM}Scripts${RESET} ${TEAL}${scriptCount}${RESET} ${DIM}Templates${RESET} ${TEAL}${tmplCount}${RESET}`);
1009
+ console.log(` ${DIM}Targets${RESET} ${TEAL}${targetLabel(target)}${RESET}`);
1010
+ console.log(` ${DIM}Time${RESET} ${TEAL}${totalSec}${RESET}`);
1011
+
1012
+ if (claudeInstalled) {
1013
+ const agentsDir = path.join(FRAMEWORK_DIR, "agents");
1014
+ const hooksSource = path.join(FRAMEWORK_DIR, "hooks");
1015
+ const rulesDir = path.join(FRAMEWORK_DIR, "rules");
1016
+ const tmplDir = path.join(FRAMEWORK_DIR, "templates");
1017
+ const skillsDir = path.join(FRAMEWORK_DIR, "skills");
1018
+ const skillCount = fs
1019
+ .readdirSync(skillsDir)
1020
+ .filter((d) => fs.statSync(path.join(skillsDir, d)).isDirectory()).length;
1021
+ const agentCount = fs.readdirSync(agentsDir).filter((f) => f.endsWith(".md")).length;
1022
+ const hookCount = fs.readdirSync(hooksSource).length;
1023
+ const ruleCount = fs.readdirSync(rulesDir).length;
1024
+ const tmplCount = fs.readdirSync(tmplDir).length;
1025
+ const installedBinDir = path.join(CLAUDE_DIR, "bin");
1026
+ const scriptCount = fs.existsSync(installedBinDir)
1027
+ ? fs.readdirSync(installedBinDir).filter((f) => f.endsWith(".js")).length
1028
+ : 0;
1029
+ console.log("");
1030
+ console.log(` ${DIM}Skills${RESET} ${TEAL}${skillCount}${RESET} ${DIM}Agents${RESET} ${TEAL}${agentCount}${RESET} ${DIM}Hooks${RESET} ${TEAL}${hookCount}${RESET}`);
1031
+ console.log(` ${DIM}Rules${RESET} ${TEAL}${ruleCount}${RESET} ${DIM}Scripts${RESET} ${TEAL}${scriptCount}${RESET} ${DIM}Templates${RESET} ${TEAL}${tmplCount}${RESET}`);
1032
+ }
794
1033
 
795
1034
  if (errors > 0) {
796
1035
  console.log("");
797
1036
  console.log(` ${YELLOW}${errors} warning(s)${RESET} — check output above`);
798
1037
  }
799
1038
 
1039
+ // Contextual first-command suggestion: if we're in a Qualia project,
1040
+ // recommend /qualia (router); otherwise /qualia-new (kickoff).
1041
+ const inProject = (() => {
1042
+ try { return fs.existsSync(path.join(process.cwd(), ".planning")); }
1043
+ catch { return false; }
1044
+ })();
1045
+ const firstCmd = inProject ? "/qualia" : "/qualia-new";
1046
+ const firstCmdHint = inProject ? "router — tells you the next command" : "kickoff a new project";
1047
+
800
1048
  console.log("");
801
- console.log(` ${DIM}${RULE}${RESET}`);
1049
+ console.log(` ${DIM2}${RULE}${RESET}`);
802
1050
  console.log(` ${WHITE}${BOLD}Quick Start${RESET}`);
803
- console.log(` ${DIM}${RULE}${RESET}`);
1051
+ console.log(` ${DIM2}${RULE}${RESET}`);
804
1052
  console.log("");
805
- console.log(` ${TEAL}1.${RESET} ${WHITE}Restart Claude Code${RESET} ${DIM}(loads new settings)${RESET}`);
806
- console.log(` ${TEAL}2.${RESET} ${WHITE}cd into any project${RESET} ${DIM}and run${RESET} ${TEAL}claude${RESET}`);
807
- console.log(` ${TEAL}3.${RESET} ${WHITE}Type${RESET} ${TEAL}${BOLD}/qualia${RESET} ${DIM}— it tells you what to do next${RESET}`);
1053
+ if (claudeInstalled) {
1054
+ console.log(` ${TEAL}1.${RESET} ${WHITE}Restart Claude Code${RESET} ${DIM}(loads new settings)${RESET}`);
1055
+ console.log(` ${TEAL}2.${RESET} ${WHITE}cd into any project${RESET} ${DIM}and run${RESET} ${TEAL}claude${RESET}`);
1056
+ console.log(` ${TEAL}3.${RESET} ${WHITE}Try${RESET} ${TEAL}${BOLD}${firstCmd}${RESET} ${DIM}— ${firstCmdHint}${RESET}`);
1057
+ } else {
1058
+ // Codex-only path
1059
+ console.log(` ${TEAL}1.${RESET} ${WHITE}Open Codex in any project${RESET}`);
1060
+ console.log(` ${TEAL}2.${RESET} ${WHITE}Codex picks up${RESET} ${TEAL}~/.codex/AGENTS.md${RESET} ${DIM}automatically${RESET}`);
1061
+ console.log(` ${TEAL}3.${RESET} ${WHITE}Ask Codex${RESET} ${DIM}about Qualia rules — they're in AGENTS.md${RESET}`);
1062
+ }
808
1063
  console.log("");
809
1064
  console.log(` ${DIM}New project?${RESET} ${TEAL}/qualia-new${RESET}`);
810
1065
  console.log(` ${DIM}Quick fix?${RESET} ${TEAL}/qualia-quick${RESET}`);
811
1066
  console.log(` ${DIM}End of day?${RESET} ${TEAL}/qualia-report${RESET} ${DIM}(mandatory)${RESET}`);
812
1067
  console.log(` ${DIM}Stuck?${RESET} ${TEAL}/qualia${RESET}`);
813
1068
  console.log("");
814
- console.log(` ${DIM}${RULE}${RESET}`);
1069
+ console.log(` ${DIM2}${RULE}${RESET}`);
815
1070
  console.log(` ${TEAL}${BOLD}Welcome to the future with Qualia.${RESET}`);
816
- console.log(` ${DIM}${RULE}${RESET}`);
1071
+ console.log(` ${DIM2}${RULE}${RESET}`);
1072
+ console.log("");
1073
+ }
1074
+
1075
+ // ─── Codex install (writes AGENTS.md to ~/.codex/) ───────
1076
+ // Scope is intentionally minimal: AGENTS.md is the open standard adopted
1077
+ // by Codex / Cursor / Continue / Aider / Devin. Codex's runtime does not
1078
+ // today consume Claude-style skills/agents/hooks on disk in a way the
1079
+ // framework can map 1:1, so we write the convention file and document the
1080
+ // scope honestly. If Codex grows skill/hook support, we extend this here.
1081
+ async function installCodex(member, target) {
817
1082
  console.log("");
1083
+ console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}Codex${RESET}`);
1084
+ console.log(` ${DIM2}${"─".repeat(40)}${RESET}`);
1085
+
1086
+ // Detect Codex CLI; soft-warn if missing (file write still proceeds).
1087
+ const { spawnSync } = require("child_process");
1088
+ let codexDetected = false;
1089
+ try {
1090
+ const r = spawnSync("codex", ["--version"], {
1091
+ encoding: "utf8",
1092
+ timeout: 3000,
1093
+ stdio: ["ignore", "pipe", "ignore"],
1094
+ });
1095
+ codexDetected = r.status === 0;
1096
+ } catch { codexDetected = false; }
1097
+
1098
+ if (!codexDetected) {
1099
+ console.log(` ${YELLOW}!${RESET} ${WHITE}Codex CLI not detected on this system${RESET}`);
1100
+ console.log(` ${DIM} Installing AGENTS.md to ~/.codex/AGENTS.md anyway — Codex will pick it up${RESET}`);
1101
+ console.log(` ${DIM} when you install via:${RESET} ${TEAL}npm install -g @openai/codex${RESET}`);
1102
+ }
1103
+
1104
+ // Make sure the dir exists.
1105
+ if (!fs.existsSync(CODEX_DIR)) {
1106
+ try {
1107
+ fs.mkdirSync(CODEX_DIR, { recursive: true });
1108
+ } catch (e) {
1109
+ warn(`Codex — could not create ${CODEX_DIR}: ${e.message}`);
1110
+ return;
1111
+ }
1112
+ }
1113
+
1114
+ // Render AGENTS.md with role substitution. Source is the same AGENTS.md
1115
+ // already shipped in the framework root (it's the cross-vendor mirror of
1116
+ // CLAUDE.md, kept under 25 lines per Pocock's instruction-budget rule).
1117
+ let agentsContent;
1118
+ try {
1119
+ agentsContent = fs.readFileSync(path.join(FRAMEWORK_DIR, "AGENTS.md"), "utf8");
1120
+ agentsContent = agentsContent
1121
+ .replace("{{ROLE}}", member.role)
1122
+ .replace("{{ROLE_DESCRIPTION}}", member.description);
1123
+ } catch (e) {
1124
+ warn(`Codex — could not read framework AGENTS.md: ${e.message}`);
1125
+ return;
1126
+ }
1127
+
1128
+ const dest = path.join(CODEX_DIR, "AGENTS.md");
1129
+
1130
+ // Backup if existing differs (matches v5.0 CLAUDE.md / settings.json
1131
+ // discipline — never silently destroy a hand-edited file).
1132
+ if (fs.existsSync(dest)) {
1133
+ try {
1134
+ const existing = fs.readFileSync(dest, "utf8");
1135
+ if (existing !== agentsContent) {
1136
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
1137
+ const bak = `${dest}.bak.${ts}`;
1138
+ try { fs.copyFileSync(dest, bak); ok(`Backed up existing AGENTS.md → ${path.basename(bak)}`); } catch {}
1139
+ }
1140
+ } catch {}
1141
+ }
1142
+
1143
+ // Atomic write: tmp + rename. Same pattern as settings.json above.
1144
+ try {
1145
+ const tmp = `${dest}.tmp.${process.pid}`;
1146
+ fs.writeFileSync(tmp, agentsContent, "utf8");
1147
+ fs.renameSync(tmp, dest);
1148
+ sectionCount++;
1149
+ ok(`AGENTS.md (configured as ${member.role})`);
1150
+ } catch (e) {
1151
+ warn(`Codex AGENTS.md — ${e.message}`);
1152
+ return;
1153
+ }
1154
+
1155
+ // Honest scope note.
1156
+ console.log(` ${DIM}└─${RESET} ${DIM}Codex install scope: AGENTS.md only — Codex's runtime does not currently${RESET}`);
1157
+ console.log(` ${DIM}consume the framework's skills/hooks/agents on disk. AGENTS.md carries${RESET}`);
1158
+ console.log(` ${DIM}the rules; commands route through Claude Code.${RESET}`);
1159
+
1160
+ // Codex-only path: still need to write the role config and print summary.
1161
+ if (target === TARGET_CODEX_ONLY) {
1162
+ printSummary({ member, target, claudeInstalled: false });
1163
+ }
818
1164
  }
819
1165
 
820
1166
  main().catch((e) => {