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.
- package/README.md +39 -26
- package/agents/roadmapper.md +1 -1
- package/bin/cli.js +339 -200
- package/bin/codex-goal.js +92 -0
- package/bin/erp-retry.js +11 -3
- package/bin/install.js +483 -55
- package/bin/knowledge-flush.js +25 -13
- package/bin/knowledge.js +11 -1
- package/bin/project-snapshot.js +293 -0
- package/bin/qualia-ui.js +13 -2
- package/bin/report-payload.js +137 -0
- package/bin/state.js +8 -1
- package/bin/statusline.js +14 -2
- package/docs/changelog-v6.html +864 -0
- package/docs/ecosystem-operating-model.md +121 -0
- package/docs/erp-contract.md +74 -21
- package/docs/onboarding.html +1 -1
- package/docs/release.md +44 -0
- package/docs/reviews/v6.2.1-revival-audit.md +53 -0
- package/docs/reviews/v6.2.2-memory-erp-audit.md +41 -0
- package/docs/reviews/v6.2.3-erp-id-guard.md +15 -0
- package/guide.md +16 -4
- package/hooks/auto-update.js +14 -7
- package/hooks/branch-guard.js +10 -2
- package/hooks/env-empty-guard.js +10 -1
- package/hooks/git-guardrails.js +10 -1
- package/hooks/migration-guard.js +4 -1
- package/hooks/pre-deploy-gate.js +38 -1
- package/hooks/pre-push.js +56 -157
- package/hooks/session-start.js +22 -14
- package/hooks/stop-session-log.js +11 -3
- package/hooks/supabase-destructive-guard.js +11 -1
- package/hooks/vercel-account-guard.js +12 -3
- package/package.json +3 -2
- package/rules/codex-goal.md +46 -0
- package/skills/qualia-build/SKILL.md +4 -0
- package/skills/qualia-feature/SKILL.md +4 -0
- package/skills/qualia-map/SKILL.md +1 -1
- package/skills/qualia-milestone/SKILL.md +1 -1
- package/skills/qualia-optimize/SKILL.md +1 -1
- package/skills/qualia-plan/SKILL.md +4 -0
- package/skills/qualia-polish/SKILL.md +2 -2
- package/skills/qualia-report/SKILL.md +6 -43
- package/skills/qualia-road/SKILL.md +1 -1
- package/skills/qualia-verify/SKILL.md +1 -1
- package/templates/help.html +1 -1
- package/templates/knowledge/agents.md +3 -3
- package/templates/knowledge/index.md +1 -1
- package/templates/tracking.json +3 -0
- package/templates/work-packet.md +46 -0
- package/tests/bin.test.sh +411 -13
- package/tests/hooks.test.sh +1 -8
- package/tests/install-smoke.test.sh +137 -0
- package/tests/published-install-smoke.test.sh +126 -0
- package/tests/refs.test.sh +42 -0
- package/tests/run-all.sh +1 -0
- package/tests/runner.js +19 -33
- package/tests/state.test.sh +4 -1
- 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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
-
//
|
|
490
|
-
// block-env-edit.js
|
|
491
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
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,
|
|
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}
|
|
1108
|
-
console.log(` ${TEAL}2.${RESET} ${WHITE}Codex
|
|
1109
|
-
console.log(` ${TEAL}3.${RESET} ${WHITE}
|
|
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
|
|
1124
|
-
//
|
|
1125
|
-
//
|
|
1126
|
-
//
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
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
|
|
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
|
-
|
|
1194
|
-
|
|
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
|
-
//
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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) {
|