qualia-framework 5.9.1 → 6.2.7

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 (81) hide show
  1. package/AGENTS.md +2 -1
  2. package/CLAUDE.md +2 -1
  3. package/README.md +45 -29
  4. package/agents/builder.md +1 -5
  5. package/agents/plan-checker.md +1 -1
  6. package/agents/planner.md +2 -6
  7. package/agents/qa-browser.md +3 -3
  8. package/agents/roadmapper.md +2 -2
  9. package/agents/verifier.md +7 -9
  10. package/agents/visual-evaluator.md +1 -3
  11. package/bin/cli.js +370 -205
  12. package/bin/erp-retry.js +11 -3
  13. package/bin/install.js +383 -55
  14. package/bin/knowledge-flush.js +25 -13
  15. package/bin/knowledge.js +11 -1
  16. package/bin/project-snapshot.js +293 -0
  17. package/bin/qualia-ui.js +13 -2
  18. package/bin/report-payload.js +137 -0
  19. package/bin/slop-detect.mjs +81 -9
  20. package/bin/state.js +8 -1
  21. package/bin/statusline.js +14 -2
  22. package/docs/archive/CHANGELOG-pre-v4.md +855 -0
  23. package/docs/changelog-v6.html +864 -0
  24. package/docs/ecosystem-operating-model.md +121 -0
  25. package/docs/erp-contract.md +74 -21
  26. package/docs/onboarding.html +2 -2
  27. package/docs/release.md +44 -0
  28. package/docs/reviews/v6.2.1-revival-audit.md +53 -0
  29. package/docs/reviews/v6.2.2-memory-erp-audit.md +41 -0
  30. package/docs/reviews/v6.2.3-erp-id-guard.md +15 -0
  31. package/guide.md +28 -3
  32. package/hooks/auto-update.js +20 -10
  33. package/hooks/branch-guard.js +10 -2
  34. package/hooks/env-empty-guard.js +15 -5
  35. package/hooks/git-guardrails.js +10 -1
  36. package/hooks/migration-guard.js +4 -1
  37. package/hooks/pre-deploy-gate.js +11 -1
  38. package/hooks/pre-push.js +43 -106
  39. package/hooks/session-start.js +22 -14
  40. package/hooks/stop-session-log.js +11 -3
  41. package/hooks/supabase-destructive-guard.js +11 -1
  42. package/hooks/vercel-account-guard.js +12 -3
  43. package/package.json +4 -3
  44. package/qualia-design/design-reference.md +2 -1
  45. package/qualia-design/frontend.md +4 -4
  46. package/rules/one-opinion.md +59 -0
  47. package/rules/trust-boundary.md +35 -0
  48. package/skills/qualia-feature/SKILL.md +5 -5
  49. package/skills/qualia-flush/SKILL.md +5 -7
  50. package/skills/qualia-hook-gen/SKILL.md +1 -1
  51. package/skills/qualia-learn/SKILL.md +1 -0
  52. package/skills/qualia-map/SKILL.md +2 -1
  53. package/skills/qualia-milestone/SKILL.md +2 -2
  54. package/skills/qualia-new/SKILL.md +6 -6
  55. package/skills/qualia-optimize/SKILL.md +1 -1
  56. package/skills/qualia-plan/SKILL.md +1 -1
  57. package/skills/qualia-polish/REFERENCE.md +8 -6
  58. package/skills/qualia-polish/SKILL.md +11 -9
  59. package/skills/qualia-polish/scripts/loop.mjs +18 -6
  60. package/skills/qualia-postmortem/SKILL.md +1 -1
  61. package/skills/qualia-report/SKILL.md +6 -42
  62. package/skills/qualia-road/SKILL.md +17 -5
  63. package/skills/qualia-verify/SKILL.md +3 -3
  64. package/skills/qualia-vibe/SKILL.md +226 -0
  65. package/skills/qualia-vibe/scripts/extract.mjs +141 -0
  66. package/skills/qualia-vibe/scripts/tokens.mjs +342 -0
  67. package/templates/help.html +10 -3
  68. package/templates/knowledge/agents.md +3 -3
  69. package/templates/knowledge/index.md +1 -1
  70. package/templates/tracking.json +3 -0
  71. package/templates/work-packet.md +46 -0
  72. package/tests/bin.test.sh +423 -25
  73. package/tests/hooks.test.sh +1 -8
  74. package/tests/install-smoke.test.sh +137 -0
  75. package/tests/published-install-smoke.test.sh +126 -0
  76. package/tests/refs.test.sh +43 -1
  77. package/tests/run-all.sh +49 -0
  78. package/tests/runner.js +19 -33
  79. package/tests/slop-detect.test.sh +11 -5
  80. package/tests/state.test.sh +4 -1
  81. package/hooks/pre-compact.js +0 -125
package/bin/erp-retry.js CHANGED
@@ -40,9 +40,17 @@ const http = require("http");
40
40
  const urlLib = require("url");
41
41
 
42
42
  const HOME = os.homedir();
43
- const QUEUE_FILE = path.join(HOME, ".claude", ".erp-retry-queue.json");
44
- const API_KEY_FILE = path.join(HOME, ".claude", ".erp-api-key");
45
- const CONFIG_FILE = path.join(HOME, ".claude", ".qualia-config.json");
43
+ function qualiaHome() {
44
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
45
+ const parent = path.basename(path.dirname(__dirname));
46
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
47
+ return path.join(HOME, ".claude");
48
+ }
49
+
50
+ const QUALIA_HOME = qualiaHome();
51
+ const QUEUE_FILE = path.join(QUALIA_HOME, ".erp-retry-queue.json");
52
+ const API_KEY_FILE = path.join(QUALIA_HOME, ".erp-api-key");
53
+ const CONFIG_FILE = path.join(QUALIA_HOME, ".qualia-config.json");
46
54
 
47
55
  const MAX_GIVE_UP_ATTEMPTS = 10;
48
56
  const DEFAULT_TIMEOUT_MS = 5000;
package/bin/install.js CHANGED
@@ -58,15 +58,17 @@ const DEFAULT_TEAM = {
58
58
 
59
59
  // Load team from external file, fall back to embedded defaults.
60
60
  function loadTeam() {
61
- const teamFile = path.join(CLAUDE_DIR, ".qualia-team.json");
62
- try {
63
- if (fs.existsSync(teamFile)) {
64
- const external = JSON.parse(fs.readFileSync(teamFile, "utf8"));
65
- if (external && typeof external === "object" && Object.keys(external).length > 0) {
66
- return external;
61
+ for (const home of [CLAUDE_DIR, CODEX_DIR]) {
62
+ const teamFile = path.join(home, ".qualia-team.json");
63
+ try {
64
+ if (fs.existsSync(teamFile)) {
65
+ const external = JSON.parse(fs.readFileSync(teamFile, "utf8"));
66
+ if (external && typeof external === "object" && Object.keys(external).length > 0) {
67
+ return external;
68
+ }
67
69
  }
68
- }
69
- } catch {}
70
+ } catch {}
71
+ }
70
72
  return DEFAULT_TEAM;
71
73
  }
72
74
 
@@ -123,6 +125,99 @@ function copyTree(src, dest) {
123
125
  }
124
126
  }
125
127
 
128
+ function codexText(content) {
129
+ return String(content)
130
+ .replaceAll("~/.claude/", "~/.codex/")
131
+ .replaceAll("$HOME/.claude/", "$HOME/.codex/")
132
+ .replaceAll("${HOME}/.claude/", "${HOME}/.codex/")
133
+ .replaceAll("@~/.claude/", "@~/.codex/")
134
+ .replaceAll(".claude/", ".codex/");
135
+ }
136
+
137
+ function copyTextTransform(src, dest, transform) {
138
+ const destDir = path.dirname(dest);
139
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
140
+ const content = fs.readFileSync(src, "utf8");
141
+ fs.writeFileSync(dest, transform(content), "utf8");
142
+ }
143
+
144
+ function copyTreeTransform(src, dest, transform) {
145
+ if (!fs.existsSync(src)) return;
146
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
147
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
148
+ if (entry.name.startsWith(".")) continue;
149
+ const srcPath = path.join(src, entry.name);
150
+ const destPath = path.join(dest, entry.name);
151
+ if (entry.isDirectory()) {
152
+ copyTreeTransform(srcPath, destPath, transform);
153
+ } else if (entry.isFile()) {
154
+ copyTextTransform(srcPath, destPath, transform);
155
+ }
156
+ }
157
+ }
158
+
159
+ function backupIfDifferent(dest, nextContent, label) {
160
+ if (!fs.existsSync(dest)) return false;
161
+ try {
162
+ const existing = fs.readFileSync(dest, "utf8");
163
+ if (existing === nextContent) return false;
164
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
165
+ const bak = `${dest}.bak.${ts}`;
166
+ fs.copyFileSync(dest, bak);
167
+ ok(`Backed up existing ${label} -> ${path.basename(bak)}`);
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ function atomicWrite(dest, content, mode) {
175
+ const destDir = path.dirname(dest);
176
+ if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
177
+ const tmp = `${dest}.tmp.${process.pid}`;
178
+ if (mode) fs.writeFileSync(tmp, content, { mode });
179
+ else fs.writeFileSync(tmp, content, "utf8");
180
+ fs.renameSync(tmp, dest);
181
+ if (mode) {
182
+ try { fs.chmodSync(dest, mode); } catch {}
183
+ }
184
+ }
185
+
186
+ function tomlString(value) {
187
+ return JSON.stringify(String(value == null ? "" : value));
188
+ }
189
+
190
+ function parseAgentMarkdown(content) {
191
+ const result = { name: "", description: "", body: content };
192
+ if (!content.startsWith("---\n")) return result;
193
+ const end = content.indexOf("\n---", 4);
194
+ if (end === -1) return result;
195
+ const fm = content.slice(4, end).trim().split(/\r?\n/);
196
+ for (const line of fm) {
197
+ const match = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/);
198
+ if (!match) continue;
199
+ const key = match[1];
200
+ const value = match[2].replace(/^["']|["']$/g, "").trim();
201
+ if (key === "name") result.name = value;
202
+ if (key === "description") result.description = value;
203
+ }
204
+ result.body = content.slice(end + "\n---".length).replace(/^\s+/, "");
205
+ return result;
206
+ }
207
+
208
+ function renderCodexAgentToml(markdown) {
209
+ const parsed = parseAgentMarkdown(markdown);
210
+ const body = parsed.body
211
+ .replaceAll("~/.claude/", "~/.codex/")
212
+ .replaceAll("@~/.claude/", "@~/.codex/");
213
+ const description = parsed.description || "Qualia Framework specialist agent.";
214
+ return [
215
+ `description = ${tomlString(description)}`,
216
+ `developer_instructions = ${tomlString(body)}`,
217
+ "",
218
+ ].join("\n");
219
+ }
220
+
126
221
  // Surgically remove orphaned v2.6 install cruft from ~/.claude/ on upgrade.
127
222
  // v2.6 installed a separate ~/.claude/qualia-framework/ directory with workflows/,
128
223
  // references/, assets/, bin/qualia-tools.js. v3 doesn't use any of that — it was
@@ -286,7 +381,7 @@ function askTarget() {
286
381
  console.log(` ${WHITE}Where would you like to install Qualia?${RESET}`);
287
382
  console.log("");
288
383
  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}`);
384
+ console.log(` ${TEAL}[2]${RESET} ${WHITE}OpenAI Codex only${RESET} ${DIM}— AGENTS.md + hooks + agents + runtime${RESET}`);
290
385
  console.log(` ${TEAL}[3]${RESET} ${WHITE}Both${RESET} ${DIM}— max compatibility${RESET}`);
291
386
  console.log("");
292
387
 
@@ -486,9 +581,14 @@ async function main() {
486
581
  }
487
582
  }
488
583
  } catch {}
489
- // v3.2.0: purge deprecated hooks from existing installs on upgrade.
490
- // block-env-edit.js was retired — team now has full read/write on .env*.
491
- const DEPRECATED_HOOKS = ["block-env-edit.js"];
584
+ // Purge deprecated hooks from existing installs on upgrade.
585
+ // - block-env-edit.js (v3.2.0): team now has full read/write on .env*.
586
+ // - pre-compact.js (v6.2.0): bot-committed STATE.md + tracking.json on
587
+ // context compaction for ERP visibility. ERP never read tracking.json
588
+ // from git, and state.js already provides crash-safe atomic writes with
589
+ // a write-ahead journal (state.js:36-64) — the bot commit added no
590
+ // durability, just history noise.
591
+ const DEPRECATED_HOOKS = ["block-env-edit.js", "pre-compact.js"];
492
592
  for (const f of DEPRECATED_HOOKS) {
493
593
  const p = path.join(hooksDest, f);
494
594
  try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch {}
@@ -671,6 +771,18 @@ async function main() {
671
771
  );
672
772
  fs.chmodSync(path.join(binDest, "erp-retry.js"), 0o755);
673
773
  ok("erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)");
774
+ copy(
775
+ path.join(FRAMEWORK_DIR, "bin", "report-payload.js"),
776
+ path.join(binDest, "report-payload.js")
777
+ );
778
+ fs.chmodSync(path.join(binDest, "report-payload.js"), 0o755);
779
+ ok("report-payload.js (Framework -> ERP report payload builder)");
780
+ copy(
781
+ path.join(FRAMEWORK_DIR, "bin", "project-snapshot.js"),
782
+ path.join(binDest, "project-snapshot.js")
783
+ );
784
+ fs.chmodSync(path.join(binDest, "project-snapshot.js"), 0o755);
785
+ ok("project-snapshot.js (ERP/admin project progress snapshot)");
674
786
  } catch (e) {
675
787
  warn(`scripts — ${e.message}`);
676
788
  }
@@ -835,7 +947,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
835
947
  try { fs.chmodSync(configFile, 0o600); } catch {}
836
948
  } catch {}
837
949
  log(`${YELLOW}!${RESET} ERP key not configured — reports won't upload until set.`);
838
- log(`${DIM} Set with:${RESET} ${TEAL}qualia-framework set-erp-key <key>${RESET}`);
950
+ log(`${DIM} Set with:${RESET} ${TEAL}printf '%s' "$QUALIA_ERP_KEY" | qualia-framework set-erp-key${RESET}`);
839
951
  log(`${DIM} or:${RESET} ${TEAL}export QUALIA_ERP_KEY=...${RESET} ${DIM}then re-install.${RESET}`);
840
952
  log(`${DIM} Get a key from Fawzi.${RESET}`);
841
953
  }
@@ -907,7 +1019,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
907
1019
  "⬢ Feature branches only — never push to main",
908
1020
  "⬢ Read before write — no exceptions",
909
1021
  "⬢ MVP first — build what's asked, nothing extra",
910
- "⬢ tracking.json syncs to ERP on every push",
1022
+ "⬢ tracking.json is local telemetry no git pollution",
911
1023
  ],
912
1024
  };
913
1025
 
@@ -961,14 +1073,11 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
961
1073
  ],
962
1074
  },
963
1075
  ],
964
- PreCompact: [
965
- {
966
- matcher: "compact",
967
- hooks: [
968
- { type: "command", command: nodeCmd("pre-compact.js"), timeout: 15, statusMessage: "⬢ Saving state..." },
969
- ],
970
- },
971
- ],
1076
+ // v6.2.0: PreCompact intentionally empty. The qualiaHooks loop in the
1077
+ // settings.json merge below still iterates over this key, so legacy
1078
+ // Qualia-owned pre-compact.js entries get stripped from existing user
1079
+ // settings on upgrade. Nothing new is wired in.
1080
+ PreCompact: [],
972
1081
  Stop: [
973
1082
  {
974
1083
  matcher: ".*",
@@ -991,7 +1100,15 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
991
1100
  if (kept.length > 0) cleaned.push({ ...block, hooks: kept });
992
1101
  }
993
1102
  // Append our canonical blocks after the preserved user ones.
994
- settings.hooks[event] = [...cleaned, ...qualiaHooks[event]];
1103
+ const merged = [...cleaned, ...qualiaHooks[event]];
1104
+ if (merged.length > 0) {
1105
+ settings.hooks[event] = merged;
1106
+ } else {
1107
+ // No hooks left for this event (e.g. PreCompact after v6.2.0 removal) —
1108
+ // drop the key entirely rather than leaving an empty array sitting in
1109
+ // settings.json.
1110
+ delete settings.hooks[event];
1111
+ }
995
1112
  }
996
1113
 
997
1114
  // Permissions stay permissive; Qualia policy enforcement happens in hooks so
@@ -1025,7 +1142,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1025
1142
  fs.writeFileSync(settingsTmp, JSON.stringify(settings, null, 2));
1026
1143
  fs.renameSync(settingsTmp, settingsPath);
1027
1144
 
1028
- 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");
1145
+ ok("Hooks: session-start, auto-update, branch-guard, pre-push, migration-guard, deploy-gate, git-guardrails, stop-session-log, vercel-account-guard, env-empty-guard, supabase-destructive-guard");
1029
1146
  ok("Status line + spinner configured");
1030
1147
  ok("Environment variables + permissions");
1031
1148
 
@@ -1104,9 +1221,9 @@ function printSummary({ member, target, claudeInstalled }) {
1104
1221
  console.log(` ${TEAL}3.${RESET} ${WHITE}Try${RESET} ${TEAL}${BOLD}${firstCmd}${RESET} ${DIM}— ${firstCmdHint}${RESET}`);
1105
1222
  } else {
1106
1223
  // Codex-only path
1107
- console.log(` ${TEAL}1.${RESET} ${WHITE}Open Codex in any project${RESET}`);
1108
- console.log(` ${TEAL}2.${RESET} ${WHITE}Codex picks up${RESET} ${TEAL}~/.codex/AGENTS.md${RESET} ${DIM}automatically${RESET}`);
1109
- console.log(` ${TEAL}3.${RESET} ${WHITE}Ask Codex${RESET} ${DIM}about Qualia rules they're in AGENTS.md${RESET}`);
1224
+ console.log(` ${TEAL}1.${RESET} ${WHITE}Restart Codex${RESET} ${DIM}(loads hooks + agents)${RESET}`);
1225
+ console.log(` ${TEAL}2.${RESET} ${WHITE}Open Codex in any project${RESET}`);
1226
+ console.log(` ${TEAL}3.${RESET} ${WHITE}Use Qualia commands${RESET} ${DIM}from AGENTS.md with ~/.codex/bin + hooks wired${RESET}`);
1110
1227
  }
1111
1228
  console.log("");
1112
1229
  console.log(` ${DIM}New project?${RESET} ${TEAL}/qualia-new${RESET}`);
@@ -1120,12 +1237,13 @@ function printSummary({ member, target, claudeInstalled }) {
1120
1237
  console.log("");
1121
1238
  }
1122
1239
 
1123
- // ─── Codex install (writes AGENTS.md to ~/.codex/) ───────
1124
- // Scope is intentionally minimal: AGENTS.md is the open standard adopted
1125
- // by Codex / Cursor / Continue / Aider / Devin. Codex's runtime does not
1126
- // today consume Claude-style skills/agents/hooks on disk in a way the
1127
- // framework can map 1:1, so we write the convention file and document the
1128
- // scope honestly. If Codex grows skill/hook support, we extend this here.
1240
+ // ─── Codex install ───────────────────────────────────────
1241
+ // Codex now has native support for AGENTS.md, ~/.codex/agents/*.toml, and
1242
+ // ~/.codex/hooks.json. Install the framework into those Codex-native surfaces,
1243
+ // plus the same local bin/rules/templates/knowledge substrate used by the
1244
+ // Claude install. The only thing intentionally not claimed is a Claude-style
1245
+ // persistent statusLine setting; Codex exposes hook status messages today, not
1246
+ // an equivalent global status-line command.
1129
1247
  async function installCodex(member, target) {
1130
1248
  console.log("");
1131
1249
  console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}Codex${RESET}`);
@@ -1145,7 +1263,7 @@ async function installCodex(member, target) {
1145
1263
 
1146
1264
  if (!codexDetected) {
1147
1265
  console.log(` ${YELLOW}!${RESET} ${WHITE}Codex CLI not detected on this system${RESET}`);
1148
- console.log(` ${DIM} Installing AGENTS.md to ~/.codex/AGENTS.md anyway — Codex will pick it up${RESET}`);
1266
+ console.log(` ${DIM} Installing Codex files to ~/.codex/ anyway — Codex will pick them up${RESET}`);
1149
1267
  console.log(` ${DIM} when you install via:${RESET} ${TEAL}npm install -g @openai/codex${RESET}`);
1150
1268
  }
1151
1269
 
@@ -1175,24 +1293,9 @@ async function installCodex(member, target) {
1175
1293
 
1176
1294
  const dest = path.join(CODEX_DIR, "AGENTS.md");
1177
1295
 
1178
- // Backup if existing differs (matches v5.0 CLAUDE.md / settings.json
1179
- // discipline — never silently destroy a hand-edited file).
1180
- if (fs.existsSync(dest)) {
1181
- try {
1182
- const existing = fs.readFileSync(dest, "utf8");
1183
- if (existing !== agentsContent) {
1184
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
1185
- const bak = `${dest}.bak.${ts}`;
1186
- try { fs.copyFileSync(dest, bak); ok(`Backed up existing AGENTS.md → ${path.basename(bak)}`); } catch {}
1187
- }
1188
- } catch {}
1189
- }
1190
-
1191
- // Atomic write: tmp + rename. Same pattern as settings.json above.
1192
1296
  try {
1193
- const tmp = `${dest}.tmp.${process.pid}`;
1194
- fs.writeFileSync(tmp, agentsContent, "utf8");
1195
- fs.renameSync(tmp, dest);
1297
+ backupIfDifferent(dest, agentsContent, "AGENTS.md");
1298
+ atomicWrite(dest, agentsContent);
1196
1299
  sectionCount++;
1197
1300
  ok(`AGENTS.md (configured as ${member.role})`);
1198
1301
  } catch (e) {
@@ -1200,10 +1303,235 @@ async function installCodex(member, target) {
1200
1303
  return;
1201
1304
  }
1202
1305
 
1203
- // Honest scope note.
1204
- console.log(` ${DIM}└─${RESET} ${DIM}Codex install scope: AGENTS.md only Codex's runtime does not currently${RESET}`);
1205
- console.log(` ${DIM}consume the framework's skills/hooks/agents on disk. AGENTS.md carries${RESET}`);
1206
- console.log(` ${DIM}the rules; commands route through Claude Code.${RESET}`);
1306
+ // Codex treats config.toml as optional, but doctor reports a warning when it
1307
+ // is absent. Create a minimal, parseable file on fresh Codex-only homes and
1308
+ // leave existing user config untouched.
1309
+ try {
1310
+ const configToml = path.join(CODEX_DIR, "config.toml");
1311
+ if (!fs.existsSync(configToml)) {
1312
+ atomicWrite(configToml, [
1313
+ "# Created by qualia-framework install.",
1314
+ "# User settings can be added normally; Qualia runtime lives in AGENTS.md, hooks.json, agents/, and bin/.",
1315
+ "",
1316
+ "[features]",
1317
+ "hooks = true",
1318
+ "plugin_hooks = true",
1319
+ "",
1320
+ ].join("\n"));
1321
+ ok("config.toml (minimal Codex config)");
1322
+ } else {
1323
+ log(`${DIM}config.toml (kept — user has customized)${RESET}`);
1324
+ }
1325
+ } catch (e) {
1326
+ warn(`Codex config.toml — ${e.message}`);
1327
+ }
1328
+
1329
+ // Save Codex-local role config. Hooks/scripts resolve their install home at
1330
+ // runtime, so Codex-only installs must not depend on ~/.claude existing.
1331
+ try {
1332
+ const codexConfig = {
1333
+ code: Object.entries(TEAM).find(([, m]) => m.name === member.name)?.[0] || "",
1334
+ installed_by: member.name,
1335
+ role: member.role,
1336
+ version: require("../package.json").version,
1337
+ installed_at: new Date().toISOString().split("T")[0],
1338
+ erp: {
1339
+ enabled: true,
1340
+ url: "https://portal.qualiasolutions.net",
1341
+ api_key_file: ".erp-api-key",
1342
+ },
1343
+ };
1344
+ atomicWrite(path.join(CODEX_DIR, ".qualia-config.json"), JSON.stringify(codexConfig, null, 2) + "\n", 0o600);
1345
+ ok(".qualia-config.json");
1346
+ } catch (e) {
1347
+ warn(`Codex config — ${e.message}`);
1348
+ }
1349
+
1350
+ // Scripts
1351
+ try {
1352
+ const binDest = path.join(CODEX_DIR, "bin");
1353
+ if (!fs.existsSync(binDest)) fs.mkdirSync(binDest, { recursive: true });
1354
+ const scripts = [
1355
+ "state.js",
1356
+ "qualia-ui.js",
1357
+ "statusline.js",
1358
+ "knowledge.js",
1359
+ "knowledge-flush.js",
1360
+ "plan-contract.js",
1361
+ "agent-runs.js",
1362
+ "slop-detect.mjs",
1363
+ "erp-retry.js",
1364
+ "report-payload.js",
1365
+ "project-snapshot.js",
1366
+ ];
1367
+ for (const script of scripts) {
1368
+ const src = path.join(FRAMEWORK_DIR, "bin", script);
1369
+ const out = path.join(binDest, script);
1370
+ copy(src, out);
1371
+ try { fs.chmodSync(out, 0o755); } catch {}
1372
+ }
1373
+ ok(`bin/ (${scripts.length} scripts)`);
1374
+ } catch (e) {
1375
+ warn(`Codex scripts — ${e.message}`);
1376
+ }
1377
+
1378
+ // Agents: convert Claude markdown agents into Codex TOML agents.
1379
+ try {
1380
+ const agentsDir = path.join(FRAMEWORK_DIR, "agents");
1381
+ const agentsDest = path.join(CODEX_DIR, "agents");
1382
+ if (!fs.existsSync(agentsDest)) fs.mkdirSync(agentsDest, { recursive: true });
1383
+ for (const file of fs.readdirSync(agentsDir)) {
1384
+ if (!file.endsWith(".md")) continue;
1385
+ const source = fs.readFileSync(path.join(agentsDir, file), "utf8");
1386
+ const parsed = parseAgentMarkdown(source);
1387
+ const base = parsed.name ? parsed.name.replace(/^qualia-/, "") : path.basename(file, ".md");
1388
+ const out = path.join(agentsDest, `${base}.toml`);
1389
+ const toml = renderCodexAgentToml(source);
1390
+ backupIfDifferent(out, toml, `agents/${base}.toml`);
1391
+ atomicWrite(out, toml);
1392
+ }
1393
+ ok("agents/*.toml");
1394
+ } catch (e) {
1395
+ warn(`Codex agents — ${e.message}`);
1396
+ }
1397
+
1398
+ // Rules + lazy-loaded design substrate.
1399
+ try {
1400
+ const rulesDir = path.join(FRAMEWORK_DIR, "rules");
1401
+ const rulesDest = path.join(CODEX_DIR, "rules");
1402
+ if (!fs.existsSync(rulesDest)) fs.mkdirSync(rulesDest, { recursive: true });
1403
+ for (const file of fs.readdirSync(rulesDir)) {
1404
+ copy(path.join(rulesDir, file), path.join(rulesDest, file));
1405
+ }
1406
+ copyTree(path.join(FRAMEWORK_DIR, "qualia-design"), path.join(CODEX_DIR, "qualia-design"));
1407
+ ok("rules/ + qualia-design/");
1408
+ } catch (e) {
1409
+ warn(`Codex rules/design — ${e.message}`);
1410
+ }
1411
+
1412
+ // Skills are copied for reference and path parity, with ~/.claude command
1413
+ // paths rewritten to ~/.codex so Codex-only installs do not depend on a
1414
+ // Claude install existing.
1415
+ try {
1416
+ const skillsSrc = path.join(FRAMEWORK_DIR, "skills");
1417
+ const skillsDest = path.join(CODEX_DIR, "skills");
1418
+ for (const skill of fs.readdirSync(skillsSrc)) {
1419
+ const src = path.join(skillsSrc, skill);
1420
+ if (!fs.statSync(src).isDirectory()) continue;
1421
+ copyTreeTransform(src, path.join(skillsDest, skill), codexText);
1422
+ }
1423
+ ok("skills/");
1424
+ } catch (e) {
1425
+ warn(`Codex skills — ${e.message}`);
1426
+ }
1427
+
1428
+ // Templates + knowledge layer.
1429
+ try {
1430
+ const tmplDir = path.join(FRAMEWORK_DIR, "templates");
1431
+ const tmplDest = path.join(CODEX_DIR, "qualia-templates");
1432
+ if (!fs.existsSync(tmplDest)) fs.mkdirSync(tmplDest, { recursive: true });
1433
+ for (const entry of fs.readdirSync(tmplDir, { withFileTypes: true })) {
1434
+ if (entry.name.startsWith(".") || entry.name === "knowledge") continue;
1435
+ const srcPath = path.join(tmplDir, entry.name);
1436
+ const destPath = path.join(tmplDest, entry.name);
1437
+ if (entry.isDirectory()) copyTreeTransform(srcPath, destPath, codexText);
1438
+ else copyTextTransform(srcPath, destPath, codexText);
1439
+ }
1440
+ const referencesSrc = path.join(FRAMEWORK_DIR, "references");
1441
+ if (fs.existsSync(referencesSrc)) {
1442
+ copyTreeTransform(referencesSrc, path.join(CODEX_DIR, "qualia-references"), codexText);
1443
+ }
1444
+ const knowledgeSrc = path.join(FRAMEWORK_DIR, "templates", "knowledge");
1445
+ const knowledgeDest = path.join(CODEX_DIR, "knowledge");
1446
+ if (!fs.existsSync(path.join(knowledgeDest, "daily-log"))) {
1447
+ fs.mkdirSync(path.join(knowledgeDest, "daily-log"), { recursive: true });
1448
+ }
1449
+ if (fs.existsSync(knowledgeSrc)) {
1450
+ for (const file of fs.readdirSync(knowledgeSrc)) {
1451
+ const out = path.join(knowledgeDest, file);
1452
+ if (!fs.existsSync(out)) copyTextTransform(path.join(knowledgeSrc, file), out, codexText);
1453
+ }
1454
+ }
1455
+ copyTextTransform(path.join(FRAMEWORK_DIR, "guide.md"), path.join(CODEX_DIR, "qualia-guide.md"), codexText);
1456
+ ok("templates/ + knowledge/ + references/ + guide");
1457
+ } catch (e) {
1458
+ warn(`Codex templates/knowledge — ${e.message}`);
1459
+ }
1460
+
1461
+ // Hooks: Codex reads ~/.codex/hooks.json. Use Codex-local hook script paths
1462
+ // and statusMessage strings so the TUI surfaces Qualia activity inline.
1463
+ try {
1464
+ const hooksSource = path.join(FRAMEWORK_DIR, "hooks");
1465
+ const hooksDest = path.join(CODEX_DIR, "hooks");
1466
+ if (!fs.existsSync(hooksDest)) fs.mkdirSync(hooksDest, { recursive: true });
1467
+ const hookFiles = fs.readdirSync(hooksSource).filter((f) => f.endsWith(".js"));
1468
+ for (const file of hookFiles) {
1469
+ const out = path.join(hooksDest, file);
1470
+ copy(path.join(hooksSource, file), out);
1471
+ try { fs.chmodSync(out, 0o755); } catch {}
1472
+ }
1473
+ const nodeCmd = (hookFile) => `node "${path.join(hooksDest, hookFile)}"`;
1474
+ const qualiaHooks = {
1475
+ hooks: {
1476
+ SessionStart: [
1477
+ { matcher: ".*", hooks: [{ type: "command", command: nodeCmd("session-start.js"), timeout: 5 }] },
1478
+ ],
1479
+ PreToolUse: [
1480
+ {
1481
+ matcher: "Bash",
1482
+ hooks: [
1483
+ { type: "command", command: nodeCmd("auto-update.js"), timeout: 5, statusMessage: "Qualia update check..." },
1484
+ { type: "command", command: nodeCmd("git-guardrails.js"), timeout: 5, statusMessage: "Qualia git safety..." },
1485
+ { type: "command", if: "Bash(git push*)", command: nodeCmd("branch-guard.js"), timeout: 5, statusMessage: "Qualia branch guard..." },
1486
+ { type: "command", if: "Bash(git push*)", command: nodeCmd("pre-push.js"), timeout: 15, statusMessage: "Qualia tracking stamp..." },
1487
+ { type: "command", if: "Bash(vercel --prod*)", command: nodeCmd("pre-deploy-gate.js"), timeout: 180, statusMessage: "Qualia deploy gate..." },
1488
+ { type: "command", if: "Bash(vercel --prod*)|Bash(vercel deploy*)", command: nodeCmd("vercel-account-guard.js"), timeout: 8, statusMessage: "Qualia Vercel account..." },
1489
+ { type: "command", if: "Bash(vercel env*)", command: nodeCmd("env-empty-guard.js"), timeout: 5, statusMessage: "Qualia env guard..." },
1490
+ { type: "command", if: "Bash(supabase*)|Bash(npx supabase*)", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5, statusMessage: "Qualia Supabase guard..." },
1491
+ ],
1492
+ },
1493
+ {
1494
+ matcher: "Edit|Write",
1495
+ hooks: [
1496
+ { type: "command", if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)", command: nodeCmd("migration-guard.js"), timeout: 10, statusMessage: "Qualia migration guard..." },
1497
+ ],
1498
+ },
1499
+ ],
1500
+ Stop: [
1501
+ { matcher: ".*", hooks: [{ type: "command", command: nodeCmd("stop-session-log.js"), timeout: 5 }] },
1502
+ ],
1503
+ },
1504
+ };
1505
+ const hooksPath = path.join(CODEX_DIR, "hooks.json");
1506
+ let hooksJson = { hooks: {} };
1507
+ if (fs.existsSync(hooksPath)) {
1508
+ try {
1509
+ const parsed = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
1510
+ if (parsed && typeof parsed === "object") hooksJson = parsed;
1511
+ } catch {}
1512
+ }
1513
+ if (!hooksJson.hooks || typeof hooksJson.hooks !== "object") hooksJson.hooks = {};
1514
+ const isQualiaHookCmd = (cmd) => {
1515
+ if (typeof cmd !== "string") return false;
1516
+ return hookFiles.some((file) => cmd.includes(file));
1517
+ };
1518
+ for (const event of Object.keys(qualiaHooks.hooks)) {
1519
+ const existing = Array.isArray(hooksJson.hooks[event]) ? hooksJson.hooks[event] : [];
1520
+ const cleaned = [];
1521
+ for (const block of existing) {
1522
+ if (!block || !Array.isArray(block.hooks)) continue;
1523
+ const kept = block.hooks.filter((h) => !isQualiaHookCmd(h && h.command));
1524
+ if (kept.length > 0) cleaned.push({ ...block, hooks: kept });
1525
+ }
1526
+ hooksJson.hooks[event] = [...cleaned, ...qualiaHooks.hooks[event]];
1527
+ }
1528
+ const content = JSON.stringify(hooksJson, null, 2) + "\n";
1529
+ backupIfDifferent(hooksPath, content, "hooks.json");
1530
+ atomicWrite(hooksPath, content);
1531
+ ok(`hooks.json + hooks/ (${hookFiles.length} hooks)`);
1532
+ } catch (e) {
1533
+ warn(`Codex hooks — ${e.message}`);
1534
+ }
1207
1535
 
1208
1536
  // Codex-only path: still need to write the role config and print summary.
1209
1537
  if (target === TARGET_CODEX_ONLY) {
@@ -5,8 +5,8 @@
5
5
  // CI/scheduled job) without an interactive Claude Code session. Closes the
6
6
  // memory loop end-to-end:
7
7
  //
8
- // Stop hook (auto, every turn) → ~/.claude/knowledge/daily-log/{date}.md
9
- // THIS SCRIPT (weekly cron) → spawns `claude -p "/qualia-flush --days 7"`
8
+ // Stop hook (auto, every turn) → <install-home>/knowledge/daily-log/{date}.md
9
+ // THIS SCRIPT (weekly cron) → spawns the installed agent CLI
10
10
  // /qualia-flush → promotes raw → curated tier
11
11
  // bin/knowledge.js (every spawn) → reads index.md → reaches the right file
12
12
  //
@@ -20,11 +20,11 @@
20
20
  // 0 3 * * 0 node ~/.claude/bin/knowledge-flush.js >> ~/.claude/.qualia-flush.log 2>&1
21
21
  //
22
22
  // Behavior:
23
- // • If `claude` CLI isn't on PATH, exits 0 with a logged warning. Cron
23
+ // • If the required agent CLI isn't on PATH, exits 0 with a logged warning. Cron
24
24
  // spam is worse than a missed flush — a real failure surfaces in the
25
25
  // log file the user is presumably watching.
26
26
  // • If the daily-log dir is empty (nothing to flush), exits 0 silently.
27
- // • If `claude -p` returns non-zero, exits 1 with the error captured in
27
+ // • If the agent CLI returns non-zero, exits 1 with the error captured in
28
28
  // the log so cron can be configured to alert on it.
29
29
  // • Writes one structured JSONL line per run to ~/.claude/.qualia-flush.log
30
30
  // so the user can audit "when did the last 5 flushes run, what did they
@@ -38,9 +38,17 @@ const os = require("os");
38
38
  const { spawnSync } = require("child_process");
39
39
 
40
40
  const HOME = os.homedir();
41
- const KNOWLEDGE_DIR = path.join(HOME, ".claude", "knowledge");
41
+ function qualiaHome() {
42
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
43
+ const parent = path.basename(path.dirname(__dirname));
44
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
45
+ return path.join(HOME, ".claude");
46
+ }
47
+
48
+ const QUALIA_HOME = qualiaHome();
49
+ const KNOWLEDGE_DIR = path.join(QUALIA_HOME, "knowledge");
42
50
  const DAILY_DIR = path.join(KNOWLEDGE_DIR, "daily-log");
43
- const LOG_FILE = path.join(HOME, ".claude", ".qualia-flush.log");
51
+ const LOG_FILE = path.join(QUALIA_HOME, ".qualia-flush.log");
44
52
 
45
53
  const _start = Date.now();
46
54
 
@@ -103,9 +111,11 @@ function dailyLogHasRecentEntries(windowDays) {
103
111
  }
104
112
 
105
113
  // ── Preflight ────────────────────────────────────────────
106
- const claudeBin = which("claude");
107
- if (!claudeBin) {
108
- logEvent({ event: "skipped", reason: "claude-cli-not-on-path" });
114
+ const IS_CODEX_INSTALL = path.basename(QUALIA_HOME) === ".codex";
115
+ const agentCli = IS_CODEX_INSTALL ? "codex" : "claude";
116
+ const agentBin = which(agentCli);
117
+ if (!agentBin) {
118
+ logEvent({ event: "skipped", reason: `${agentCli}-cli-not-on-path` });
109
119
  // Exit 0 — a missing CLI on the host running cron is a config issue, not
110
120
  // a flush failure. Don't spam alerts.
111
121
  process.exit(0);
@@ -117,11 +127,13 @@ if (!dailyLogHasRecentEntries(days)) {
117
127
  }
118
128
 
119
129
  // ── Run ──────────────────────────────────────────────────
120
- // `claude -p "<prompt>"` runs a single non-interactive turn. The skill body
121
- // invocation matches what the user would type at the prompt.
130
+ // `claude -p "<prompt>"` and `codex exec "<prompt>"` run a single
131
+ // non-interactive turn. The skill body invocation matches what the user would
132
+ // type at the prompt.
122
133
  const prompt = `/qualia-flush ${argv.join(" ")}`.trim();
123
134
 
124
- const result = spawnSync(claudeBin, ["-p", prompt], {
135
+ const cliArgs = IS_CODEX_INSTALL ? ["exec", prompt] : ["-p", prompt];
136
+ const result = spawnSync(agentBin, cliArgs, {
125
137
  encoding: "utf8",
126
138
  timeout: 5 * 60 * 1000, // 5 min hard cap — flush should never take this long
127
139
  shell: process.platform === "win32",
@@ -140,7 +152,7 @@ if (status !== 0) {
140
152
  stderr_tail: stderr.slice(-1000),
141
153
  });
142
154
  // Surface to stderr so cron's MAILTO sends an alert.
143
- console.error(`knowledge-flush: claude -p exited ${status}`);
155
+ console.error(`knowledge-flush: ${agentCli} exited ${status}`);
144
156
  if (stderr) console.error(stderr.slice(-2000));
145
157
  process.exit(1);
146
158
  }