qualia-framework 6.1.0 → 6.2.9

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 (59) hide show
  1. package/README.md +39 -26
  2. package/agents/roadmapper.md +1 -1
  3. package/bin/cli.js +339 -200
  4. package/bin/codex-goal.js +92 -0
  5. package/bin/erp-retry.js +11 -3
  6. package/bin/install.js +483 -55
  7. package/bin/knowledge-flush.js +25 -13
  8. package/bin/knowledge.js +11 -1
  9. package/bin/project-snapshot.js +293 -0
  10. package/bin/qualia-ui.js +13 -2
  11. package/bin/report-payload.js +137 -0
  12. package/bin/state.js +8 -1
  13. package/bin/statusline.js +14 -2
  14. package/docs/changelog-v6.html +864 -0
  15. package/docs/ecosystem-operating-model.md +121 -0
  16. package/docs/erp-contract.md +74 -21
  17. package/docs/onboarding.html +1 -1
  18. package/docs/release.md +44 -0
  19. package/docs/reviews/v6.2.1-revival-audit.md +53 -0
  20. package/docs/reviews/v6.2.2-memory-erp-audit.md +41 -0
  21. package/docs/reviews/v6.2.3-erp-id-guard.md +15 -0
  22. package/guide.md +16 -4
  23. package/hooks/auto-update.js +14 -7
  24. package/hooks/branch-guard.js +10 -2
  25. package/hooks/env-empty-guard.js +10 -1
  26. package/hooks/git-guardrails.js +10 -1
  27. package/hooks/migration-guard.js +4 -1
  28. package/hooks/pre-deploy-gate.js +38 -1
  29. package/hooks/pre-push.js +56 -157
  30. package/hooks/session-start.js +22 -14
  31. package/hooks/stop-session-log.js +11 -3
  32. package/hooks/supabase-destructive-guard.js +11 -1
  33. package/hooks/vercel-account-guard.js +12 -3
  34. package/package.json +3 -2
  35. package/rules/codex-goal.md +46 -0
  36. package/skills/qualia-build/SKILL.md +4 -0
  37. package/skills/qualia-feature/SKILL.md +4 -0
  38. package/skills/qualia-map/SKILL.md +1 -1
  39. package/skills/qualia-milestone/SKILL.md +1 -1
  40. package/skills/qualia-optimize/SKILL.md +1 -1
  41. package/skills/qualia-plan/SKILL.md +4 -0
  42. package/skills/qualia-polish/SKILL.md +2 -2
  43. package/skills/qualia-report/SKILL.md +6 -43
  44. package/skills/qualia-road/SKILL.md +1 -1
  45. package/skills/qualia-verify/SKILL.md +1 -1
  46. package/templates/help.html +1 -1
  47. package/templates/knowledge/agents.md +3 -3
  48. package/templates/knowledge/index.md +1 -1
  49. package/templates/tracking.json +3 -0
  50. package/templates/work-packet.md +46 -0
  51. package/tests/bin.test.sh +411 -13
  52. package/tests/hooks.test.sh +1 -8
  53. package/tests/install-smoke.test.sh +137 -0
  54. package/tests/published-install-smoke.test.sh +126 -0
  55. package/tests/refs.test.sh +42 -0
  56. package/tests/run-all.sh +1 -0
  57. package/tests/runner.js +19 -33
  58. package/tests/state.test.sh +4 -1
  59. package/hooks/pre-compact.js +0 -127
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,128 @@ 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, filenameFallback) {
209
+ const parsed = parseAgentMarkdown(markdown);
210
+ const body = parsed.body
211
+ .replaceAll("~/.claude/", "~/.codex/")
212
+ .replaceAll("@~/.claude/", "@~/.codex/");
213
+ const name = (parsed.name || filenameFallback || "").replace(/^qualia-/, "");
214
+ const description = parsed.description || "Qualia Framework specialist agent.";
215
+ return [
216
+ `name = ${tomlString(name)}`,
217
+ `description = ${tomlString(description)}`,
218
+ `developer_instructions = ${tomlString(body)}`,
219
+ "",
220
+ ].join("\n");
221
+ }
222
+
223
+ // Skills removed in past versions but still present in older installs.
224
+ // Pruned from BOTH ~/.claude/skills/ and ~/.codex/skills/ on every install run
225
+ // so the active surface matches what the framework currently ships.
226
+ const DEPRECATED_SKILLS = [
227
+ "qualia-task", // v5.7.0 — folded into qualia-feature
228
+ "qualia-quick", // v5.7.0 — folded into qualia-feature
229
+ "qualia-polish-loop", // v5.8.0 — folded into qualia-polish --loop
230
+ "qualia-design", // v4 wave 2 — folded into scope-adaptive qualia-polish
231
+ "qualia-prd", // v5.8.0 — surface cleanup
232
+ ];
233
+
234
+ function pruneDeprecatedSkills(baseDir) {
235
+ const skillsDir = path.join(baseDir, "skills");
236
+ if (!fs.existsSync(skillsDir)) return [];
237
+ const removed = [];
238
+ for (const name of DEPRECATED_SKILLS) {
239
+ const target = path.join(skillsDir, name);
240
+ try {
241
+ if (fs.existsSync(target)) {
242
+ fs.rmSync(target, { recursive: true, force: true });
243
+ removed.push(name);
244
+ }
245
+ } catch {}
246
+ }
247
+ return removed;
248
+ }
249
+
126
250
  // Surgically remove orphaned v2.6 install cruft from ~/.claude/ on upgrade.
127
251
  // v2.6 installed a separate ~/.claude/qualia-framework/ directory with workflows/,
128
252
  // references/, assets/, bin/qualia-tools.js. v3 doesn't use any of that — it was
@@ -286,7 +410,7 @@ function askTarget() {
286
410
  console.log(` ${WHITE}Where would you like to install Qualia?${RESET}`);
287
411
  console.log("");
288
412
  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}`);
413
+ console.log(` ${TEAL}[2]${RESET} ${WHITE}OpenAI Codex only${RESET} ${DIM}— AGENTS.md + hooks + agents + runtime${RESET}`);
290
414
  console.log(` ${TEAL}[3]${RESET} ${WHITE}Both${RESET} ${DIM}— max compatibility${RESET}`);
291
415
  console.log("");
292
416
 
@@ -379,6 +503,8 @@ async function main() {
379
503
  .filter((d) => fs.statSync(path.join(skillsDir, d)).isDirectory());
380
504
 
381
505
  printSection("Skills");
506
+ const claudePruned = pruneDeprecatedSkills(CLAUDE_DIR);
507
+ for (const name of claudePruned) ok(`pruned deprecated: ${name}`);
382
508
  for (const skill of skills) {
383
509
  try {
384
510
  copy(
@@ -486,9 +612,14 @@ async function main() {
486
612
  }
487
613
  }
488
614
  } 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"];
615
+ // Purge deprecated hooks from existing installs on upgrade.
616
+ // - block-env-edit.js (v3.2.0): team now has full read/write on .env*.
617
+ // - pre-compact.js (v6.2.0): bot-committed STATE.md + tracking.json on
618
+ // context compaction for ERP visibility. ERP never read tracking.json
619
+ // from git, and state.js already provides crash-safe atomic writes with
620
+ // a write-ahead journal (state.js:36-64) — the bot commit added no
621
+ // durability, just history noise.
622
+ const DEPRECATED_HOOKS = ["block-env-edit.js", "pre-compact.js"];
492
623
  for (const f of DEPRECATED_HOOKS) {
493
624
  const p = path.join(hooksDest, f);
494
625
  try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch {}
@@ -671,6 +802,24 @@ async function main() {
671
802
  );
672
803
  fs.chmodSync(path.join(binDest, "erp-retry.js"), 0o755);
673
804
  ok("erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)");
805
+ copy(
806
+ path.join(FRAMEWORK_DIR, "bin", "report-payload.js"),
807
+ path.join(binDest, "report-payload.js")
808
+ );
809
+ fs.chmodSync(path.join(binDest, "report-payload.js"), 0o755);
810
+ ok("report-payload.js (Framework -> ERP report payload builder)");
811
+ copy(
812
+ path.join(FRAMEWORK_DIR, "bin", "project-snapshot.js"),
813
+ path.join(binDest, "project-snapshot.js")
814
+ );
815
+ fs.chmodSync(path.join(binDest, "project-snapshot.js"), 0o755);
816
+ ok("project-snapshot.js (ERP/admin project progress snapshot)");
817
+ copy(
818
+ path.join(FRAMEWORK_DIR, "bin", "codex-goal.js"),
819
+ path.join(binDest, "codex-goal.js")
820
+ );
821
+ fs.chmodSync(path.join(binDest, "codex-goal.js"), 0o755);
822
+ ok("codex-goal.js (Codex /goal objective + token-budget suggester)");
674
823
  } catch (e) {
675
824
  warn(`scripts — ${e.message}`);
676
825
  }
@@ -835,7 +984,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
835
984
  try { fs.chmodSync(configFile, 0o600); } catch {}
836
985
  } catch {}
837
986
  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}`);
987
+ log(`${DIM} Set with:${RESET} ${TEAL}printf '%s' "$QUALIA_ERP_KEY" | qualia-framework set-erp-key${RESET}`);
839
988
  log(`${DIM} or:${RESET} ${TEAL}export QUALIA_ERP_KEY=...${RESET} ${DIM}then re-install.${RESET}`);
840
989
  log(`${DIM} Get a key from Fawzi.${RESET}`);
841
990
  }
@@ -907,7 +1056,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
907
1056
  "⬢ Feature branches only — never push to main",
908
1057
  "⬢ Read before write — no exceptions",
909
1058
  "⬢ MVP first — build what's asked, nothing extra",
910
- "⬢ tracking.json syncs to ERP on every push",
1059
+ "⬢ tracking.json is local telemetry no git pollution",
911
1060
  ],
912
1061
  };
913
1062
 
@@ -961,14 +1110,11 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
961
1110
  ],
962
1111
  },
963
1112
  ],
964
- PreCompact: [
965
- {
966
- matcher: "compact",
967
- hooks: [
968
- { type: "command", command: nodeCmd("pre-compact.js"), timeout: 15, statusMessage: "⬢ Saving state..." },
969
- ],
970
- },
971
- ],
1113
+ // v6.2.0: PreCompact intentionally empty. The qualiaHooks loop in the
1114
+ // settings.json merge below still iterates over this key, so legacy
1115
+ // Qualia-owned pre-compact.js entries get stripped from existing user
1116
+ // settings on upgrade. Nothing new is wired in.
1117
+ PreCompact: [],
972
1118
  Stop: [
973
1119
  {
974
1120
  matcher: ".*",
@@ -991,7 +1137,15 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
991
1137
  if (kept.length > 0) cleaned.push({ ...block, hooks: kept });
992
1138
  }
993
1139
  // Append our canonical blocks after the preserved user ones.
994
- settings.hooks[event] = [...cleaned, ...qualiaHooks[event]];
1140
+ const merged = [...cleaned, ...qualiaHooks[event]];
1141
+ if (merged.length > 0) {
1142
+ settings.hooks[event] = merged;
1143
+ } else {
1144
+ // No hooks left for this event (e.g. PreCompact after v6.2.0 removal) —
1145
+ // drop the key entirely rather than leaving an empty array sitting in
1146
+ // settings.json.
1147
+ delete settings.hooks[event];
1148
+ }
995
1149
  }
996
1150
 
997
1151
  // Permissions stay permissive; Qualia policy enforcement happens in hooks so
@@ -1025,7 +1179,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
1025
1179
  fs.writeFileSync(settingsTmp, JSON.stringify(settings, null, 2));
1026
1180
  fs.renameSync(settingsTmp, settingsPath);
1027
1181
 
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");
1182
+ 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
1183
  ok("Status line + spinner configured");
1030
1184
  ok("Environment variables + permissions");
1031
1185
 
@@ -1104,9 +1258,9 @@ function printSummary({ member, target, claudeInstalled }) {
1104
1258
  console.log(` ${TEAL}3.${RESET} ${WHITE}Try${RESET} ${TEAL}${BOLD}${firstCmd}${RESET} ${DIM}— ${firstCmdHint}${RESET}`);
1105
1259
  } else {
1106
1260
  // 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}`);
1261
+ console.log(` ${TEAL}1.${RESET} ${WHITE}Restart Codex${RESET} ${DIM}(loads hooks + agents)${RESET}`);
1262
+ console.log(` ${TEAL}2.${RESET} ${WHITE}Open Codex in any project${RESET}`);
1263
+ console.log(` ${TEAL}3.${RESET} ${WHITE}Use Qualia commands${RESET} ${DIM}from AGENTS.md with ~/.codex/bin + hooks wired${RESET}`);
1110
1264
  }
1111
1265
  console.log("");
1112
1266
  console.log(` ${DIM}New project?${RESET} ${TEAL}/qualia-new${RESET}`);
@@ -1120,12 +1274,13 @@ function printSummary({ member, target, claudeInstalled }) {
1120
1274
  console.log("");
1121
1275
  }
1122
1276
 
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.
1277
+ // ─── Codex install ───────────────────────────────────────
1278
+ // Codex now has native support for AGENTS.md, ~/.codex/agents/*.toml, and
1279
+ // ~/.codex/hooks.json. Install the framework into those Codex-native surfaces,
1280
+ // plus the same local bin/rules/templates/knowledge substrate used by the
1281
+ // Claude install. The only thing intentionally not claimed is a Claude-style
1282
+ // persistent statusLine setting; Codex exposes hook status messages today, not
1283
+ // an equivalent global status-line command.
1129
1284
  async function installCodex(member, target) {
1130
1285
  console.log("");
1131
1286
  console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}Codex${RESET}`);
@@ -1145,7 +1300,7 @@ async function installCodex(member, target) {
1145
1300
 
1146
1301
  if (!codexDetected) {
1147
1302
  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}`);
1303
+ console.log(` ${DIM} Installing Codex files to ~/.codex/ anyway — Codex will pick them up${RESET}`);
1149
1304
  console.log(` ${DIM} when you install via:${RESET} ${TEAL}npm install -g @openai/codex${RESET}`);
1150
1305
  }
1151
1306
 
@@ -1175,24 +1330,9 @@ async function installCodex(member, target) {
1175
1330
 
1176
1331
  const dest = path.join(CODEX_DIR, "AGENTS.md");
1177
1332
 
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
1333
  try {
1193
- const tmp = `${dest}.tmp.${process.pid}`;
1194
- fs.writeFileSync(tmp, agentsContent, "utf8");
1195
- fs.renameSync(tmp, dest);
1334
+ backupIfDifferent(dest, agentsContent, "AGENTS.md");
1335
+ atomicWrite(dest, agentsContent);
1196
1336
  sectionCount++;
1197
1337
  ok(`AGENTS.md (configured as ${member.role})`);
1198
1338
  } catch (e) {
@@ -1200,10 +1340,298 @@ async function installCodex(member, target) {
1200
1340
  return;
1201
1341
  }
1202
1342
 
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}`);
1343
+ // Codex treats config.toml as optional, but doctor reports a warning when it
1344
+ // is absent. Create a minimal, parseable file on fresh Codex-only homes and
1345
+ // leave existing user config untouched.
1346
+ try {
1347
+ const configToml = path.join(CODEX_DIR, "config.toml");
1348
+ if (!fs.existsSync(configToml)) {
1349
+ atomicWrite(configToml, [
1350
+ "# Created by qualia-framework install.",
1351
+ "# User settings can be added normally; Qualia runtime lives in AGENTS.md, hooks.json, agents/, and bin/.",
1352
+ "",
1353
+ "[features]",
1354
+ "hooks = true",
1355
+ "plugin_hooks = true",
1356
+ "",
1357
+ "# Codex's built-in status line is rendered at the bottom of the TUI.",
1358
+ "# It takes an ARRAY of pre-defined segment names; Codex does NOT support",
1359
+ "# custom-command status lines (unlike Claude's settings.json statusLine),",
1360
+ "# so the Qualia phase/state info is rendered via the SessionStart banner",
1361
+ "# at the top of the session instead. The segment list below mirrors the",
1362
+ "# Codex default rich layout.",
1363
+ "[tui]",
1364
+ 'status_line = ["model-with-reasoning", "task-progress", "current-dir", "git-branch", "context-used", "five-hour-limit", "weekly-limit"]',
1365
+ "status_line_use_colors = true",
1366
+ "",
1367
+ ].join("\n"));
1368
+ ok("config.toml (minimal Codex config)");
1369
+ } else {
1370
+ // Existing user config — append [tui] block only if absent. Leaves
1371
+ // every other user setting untouched.
1372
+ try {
1373
+ const existing = fs.readFileSync(configToml, "utf8");
1374
+ if (!/^\[tui\]/m.test(existing) && !/^status_line\s*=/m.test(existing)) {
1375
+ const append = [
1376
+ "",
1377
+ "# Added by qualia-framework — Codex bottom status line.",
1378
+ "[tui]",
1379
+ 'status_line = ["model-with-reasoning", "task-progress", "current-dir", "git-branch", "context-used", "five-hour-limit", "weekly-limit"]',
1380
+ "status_line_use_colors = true",
1381
+ "",
1382
+ ].join("\n");
1383
+ fs.appendFileSync(configToml, append);
1384
+ ok("config.toml (appended [tui] status line block)");
1385
+ } else {
1386
+ log(`${DIM}config.toml (kept — user has customized)${RESET}`);
1387
+ }
1388
+ } catch {
1389
+ log(`${DIM}config.toml (kept — user has customized)${RESET}`);
1390
+ }
1391
+ }
1392
+ } catch (e) {
1393
+ warn(`Codex config.toml — ${e.message}`);
1394
+ }
1395
+
1396
+ // Save Codex-local role config. Hooks/scripts resolve their install home at
1397
+ // runtime, so Codex-only installs must not depend on ~/.claude existing.
1398
+ try {
1399
+ const codexConfig = {
1400
+ code: Object.entries(TEAM).find(([, m]) => m.name === member.name)?.[0] || "",
1401
+ installed_by: member.name,
1402
+ role: member.role,
1403
+ version: require("../package.json").version,
1404
+ installed_at: new Date().toISOString().split("T")[0],
1405
+ erp: {
1406
+ enabled: true,
1407
+ url: "https://portal.qualiasolutions.net",
1408
+ api_key_file: ".erp-api-key",
1409
+ },
1410
+ };
1411
+ atomicWrite(path.join(CODEX_DIR, ".qualia-config.json"), JSON.stringify(codexConfig, null, 2) + "\n", 0o600);
1412
+ ok(".qualia-config.json");
1413
+ } catch (e) {
1414
+ warn(`Codex config — ${e.message}`);
1415
+ }
1416
+
1417
+ // Mirror the ERP API key from Claude → Codex so erp-retry/report-payload can
1418
+ // post from Codex sessions without a separate provisioning step. The key
1419
+ // resolver at runtime looks in $CODEX_DIR/.erp-api-key only; without this
1420
+ // copy, every Codex ERP write 401s and the queue grows silently.
1421
+ try {
1422
+ const claudeKey = path.join(CLAUDE_DIR, ".erp-api-key");
1423
+ const codexKey = path.join(CODEX_DIR, ".erp-api-key");
1424
+ if (fs.existsSync(claudeKey) && !fs.existsSync(codexKey)) {
1425
+ const key = fs.readFileSync(claudeKey, "utf8");
1426
+ atomicWrite(codexKey, key, 0o600);
1427
+ ok(".erp-api-key (mirrored from ~/.claude/)");
1428
+ } else if (fs.existsSync(codexKey)) {
1429
+ log(`${DIM}.erp-api-key (existing — preserved)${RESET}`);
1430
+ }
1431
+ } catch (e) {
1432
+ warn(`Codex .erp-api-key — ${e.message}`);
1433
+ }
1434
+
1435
+ // Scripts
1436
+ try {
1437
+ const binDest = path.join(CODEX_DIR, "bin");
1438
+ if (!fs.existsSync(binDest)) fs.mkdirSync(binDest, { recursive: true });
1439
+ const scripts = [
1440
+ "state.js",
1441
+ "qualia-ui.js",
1442
+ "statusline.js",
1443
+ "knowledge.js",
1444
+ "knowledge-flush.js",
1445
+ "plan-contract.js",
1446
+ "agent-runs.js",
1447
+ "slop-detect.mjs",
1448
+ "erp-retry.js",
1449
+ "report-payload.js",
1450
+ "project-snapshot.js",
1451
+ "codex-goal.js",
1452
+ ];
1453
+ for (const script of scripts) {
1454
+ const src = path.join(FRAMEWORK_DIR, "bin", script);
1455
+ const out = path.join(binDest, script);
1456
+ copy(src, out);
1457
+ try { fs.chmodSync(out, 0o755); } catch {}
1458
+ }
1459
+ ok(`bin/ (${scripts.length} scripts)`);
1460
+ } catch (e) {
1461
+ warn(`Codex scripts — ${e.message}`);
1462
+ }
1463
+
1464
+ // Agents: convert Claude markdown agents into Codex TOML agents.
1465
+ try {
1466
+ const agentsDir = path.join(FRAMEWORK_DIR, "agents");
1467
+ const agentsDest = path.join(CODEX_DIR, "agents");
1468
+ if (!fs.existsSync(agentsDest)) fs.mkdirSync(agentsDest, { recursive: true });
1469
+ for (const file of fs.readdirSync(agentsDir)) {
1470
+ if (!file.endsWith(".md")) continue;
1471
+ const source = fs.readFileSync(path.join(agentsDir, file), "utf8");
1472
+ const parsed = parseAgentMarkdown(source);
1473
+ const base = parsed.name ? parsed.name.replace(/^qualia-/, "") : path.basename(file, ".md");
1474
+ const out = path.join(agentsDest, `${base}.toml`);
1475
+ const toml = renderCodexAgentToml(source, base);
1476
+ backupIfDifferent(out, toml, `agents/${base}.toml`);
1477
+ atomicWrite(out, toml);
1478
+ }
1479
+ ok("agents/*.toml");
1480
+ } catch (e) {
1481
+ warn(`Codex agents — ${e.message}`);
1482
+ }
1483
+
1484
+ // Rules + lazy-loaded design substrate.
1485
+ try {
1486
+ const rulesDir = path.join(FRAMEWORK_DIR, "rules");
1487
+ const rulesDest = path.join(CODEX_DIR, "rules");
1488
+ if (!fs.existsSync(rulesDest)) fs.mkdirSync(rulesDest, { recursive: true });
1489
+ for (const file of fs.readdirSync(rulesDir)) {
1490
+ copy(path.join(rulesDir, file), path.join(rulesDest, file));
1491
+ }
1492
+ copyTree(path.join(FRAMEWORK_DIR, "qualia-design"), path.join(CODEX_DIR, "qualia-design"));
1493
+ ok("rules/ + qualia-design/");
1494
+ } catch (e) {
1495
+ warn(`Codex rules/design — ${e.message}`);
1496
+ }
1497
+
1498
+ // Skills are copied for reference and path parity, with ~/.claude command
1499
+ // paths rewritten to ~/.codex so Codex-only installs do not depend on a
1500
+ // Claude install existing.
1501
+ try {
1502
+ const skillsSrc = path.join(FRAMEWORK_DIR, "skills");
1503
+ const skillsDest = path.join(CODEX_DIR, "skills");
1504
+ const codexPruned = pruneDeprecatedSkills(CODEX_DIR);
1505
+ for (const name of codexPruned) ok(`pruned deprecated: ${name}`);
1506
+ for (const skill of fs.readdirSync(skillsSrc)) {
1507
+ const src = path.join(skillsSrc, skill);
1508
+ if (!fs.statSync(src).isDirectory()) continue;
1509
+ copyTreeTransform(src, path.join(skillsDest, skill), codexText);
1510
+ }
1511
+ ok("skills/");
1512
+ } catch (e) {
1513
+ warn(`Codex skills — ${e.message}`);
1514
+ }
1515
+
1516
+ // Templates + knowledge layer.
1517
+ try {
1518
+ const tmplDir = path.join(FRAMEWORK_DIR, "templates");
1519
+ const tmplDest = path.join(CODEX_DIR, "qualia-templates");
1520
+ if (!fs.existsSync(tmplDest)) fs.mkdirSync(tmplDest, { recursive: true });
1521
+ for (const entry of fs.readdirSync(tmplDir, { withFileTypes: true })) {
1522
+ if (entry.name.startsWith(".") || entry.name === "knowledge") continue;
1523
+ const srcPath = path.join(tmplDir, entry.name);
1524
+ const destPath = path.join(tmplDest, entry.name);
1525
+ if (entry.isDirectory()) copyTreeTransform(srcPath, destPath, codexText);
1526
+ else copyTextTransform(srcPath, destPath, codexText);
1527
+ }
1528
+ const referencesSrc = path.join(FRAMEWORK_DIR, "references");
1529
+ if (fs.existsSync(referencesSrc)) {
1530
+ copyTreeTransform(referencesSrc, path.join(CODEX_DIR, "qualia-references"), codexText);
1531
+ }
1532
+ const knowledgeSrc = path.join(FRAMEWORK_DIR, "templates", "knowledge");
1533
+ const knowledgeDest = path.join(CODEX_DIR, "knowledge");
1534
+ if (!fs.existsSync(path.join(knowledgeDest, "daily-log"))) {
1535
+ fs.mkdirSync(path.join(knowledgeDest, "daily-log"), { recursive: true });
1536
+ }
1537
+ if (fs.existsSync(knowledgeSrc)) {
1538
+ for (const file of fs.readdirSync(knowledgeSrc)) {
1539
+ const out = path.join(knowledgeDest, file);
1540
+ if (!fs.existsSync(out)) copyTextTransform(path.join(knowledgeSrc, file), out, codexText);
1541
+ }
1542
+ }
1543
+ copyTextTransform(path.join(FRAMEWORK_DIR, "guide.md"), path.join(CODEX_DIR, "qualia-guide.md"), codexText);
1544
+ ok("templates/ + knowledge/ + references/ + guide");
1545
+ } catch (e) {
1546
+ warn(`Codex templates/knowledge — ${e.message}`);
1547
+ }
1548
+
1549
+ // Hooks: Codex reads ~/.codex/hooks.json. Use Codex-local hook script paths
1550
+ // and statusMessage strings so the TUI surfaces Qualia activity inline.
1551
+ try {
1552
+ const hooksSource = path.join(FRAMEWORK_DIR, "hooks");
1553
+ const hooksDest = path.join(CODEX_DIR, "hooks");
1554
+ if (!fs.existsSync(hooksDest)) fs.mkdirSync(hooksDest, { recursive: true });
1555
+ const hookFiles = fs.readdirSync(hooksSource).filter((f) => f.endsWith(".js"));
1556
+ for (const file of hookFiles) {
1557
+ const out = path.join(hooksDest, file);
1558
+ copy(path.join(hooksSource, file), out);
1559
+ try { fs.chmodSync(out, 0o755); } catch {}
1560
+ }
1561
+ const nodeCmd = (hookFile) => `node "${path.join(hooksDest, hookFile)}"`;
1562
+ // Codex's hook schema does NOT include an `if` field — only `command`,
1563
+ // `commandWindows`, `timeout`, `async`, `statusMessage`. Filtering on
1564
+ // tool_input.command happens inside each hook script (they read stdin
1565
+ // JSON and `process.exit(0)` fast when the command doesn't match).
1566
+ //
1567
+ // Codex prints `statusMessage` for every entry in the matched group BEFORE
1568
+ // running the hook. Including statusMessage on conditional hooks produced
1569
+ // 8 lines of "Running PreToolUse hook: Qualia X..." on every Bash call
1570
+ // even when 6 of the 8 immediately exited 0. We only set statusMessage on
1571
+ // hooks that always do real work (auto-update + git-guardrails). The
1572
+ // conditional hooks stay registered (so they still fire when applicable)
1573
+ // but render silently.
1574
+ const qualiaHooks = {
1575
+ hooks: {
1576
+ SessionStart: [
1577
+ { matcher: ".*", hooks: [{ type: "command", command: nodeCmd("session-start.js"), timeout: 5 }] },
1578
+ ],
1579
+ PreToolUse: [
1580
+ {
1581
+ matcher: "Bash",
1582
+ hooks: [
1583
+ { type: "command", command: nodeCmd("auto-update.js"), timeout: 5, statusMessage: "Qualia update check..." },
1584
+ { type: "command", command: nodeCmd("git-guardrails.js"), timeout: 5, statusMessage: "Qualia git safety..." },
1585
+ { type: "command", command: nodeCmd("branch-guard.js"), timeout: 5 },
1586
+ { type: "command", command: nodeCmd("pre-push.js"), timeout: 15 },
1587
+ { type: "command", command: nodeCmd("pre-deploy-gate.js"), timeout: 180 },
1588
+ { type: "command", command: nodeCmd("vercel-account-guard.js"), timeout: 8 },
1589
+ { type: "command", command: nodeCmd("env-empty-guard.js"), timeout: 5 },
1590
+ { type: "command", command: nodeCmd("supabase-destructive-guard.js"), timeout: 5 },
1591
+ ],
1592
+ },
1593
+ {
1594
+ matcher: "Edit|Write",
1595
+ hooks: [
1596
+ { type: "command", command: nodeCmd("migration-guard.js"), timeout: 10 },
1597
+ ],
1598
+ },
1599
+ ],
1600
+ Stop: [
1601
+ { matcher: ".*", hooks: [{ type: "command", command: nodeCmd("stop-session-log.js"), timeout: 5 }] },
1602
+ ],
1603
+ },
1604
+ };
1605
+ const hooksPath = path.join(CODEX_DIR, "hooks.json");
1606
+ let hooksJson = { hooks: {} };
1607
+ if (fs.existsSync(hooksPath)) {
1608
+ try {
1609
+ const parsed = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
1610
+ if (parsed && typeof parsed === "object") hooksJson = parsed;
1611
+ } catch {}
1612
+ }
1613
+ if (!hooksJson.hooks || typeof hooksJson.hooks !== "object") hooksJson.hooks = {};
1614
+ const isQualiaHookCmd = (cmd) => {
1615
+ if (typeof cmd !== "string") return false;
1616
+ return hookFiles.some((file) => cmd.includes(file));
1617
+ };
1618
+ for (const event of Object.keys(qualiaHooks.hooks)) {
1619
+ const existing = Array.isArray(hooksJson.hooks[event]) ? hooksJson.hooks[event] : [];
1620
+ const cleaned = [];
1621
+ for (const block of existing) {
1622
+ if (!block || !Array.isArray(block.hooks)) continue;
1623
+ const kept = block.hooks.filter((h) => !isQualiaHookCmd(h && h.command));
1624
+ if (kept.length > 0) cleaned.push({ ...block, hooks: kept });
1625
+ }
1626
+ hooksJson.hooks[event] = [...cleaned, ...qualiaHooks.hooks[event]];
1627
+ }
1628
+ const content = JSON.stringify(hooksJson, null, 2) + "\n";
1629
+ backupIfDifferent(hooksPath, content, "hooks.json");
1630
+ atomicWrite(hooksPath, content);
1631
+ ok(`hooks.json + hooks/ (${hookFiles.length} hooks)`);
1632
+ } catch (e) {
1633
+ warn(`Codex hooks — ${e.message}`);
1634
+ }
1207
1635
 
1208
1636
  // Codex-only path: still need to write the role config and print summary.
1209
1637
  if (target === TARGET_CODEX_ONLY) {