qualia-framework-v2 2.8.0 → 2.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -0
- package/bin/cli.js +267 -5
- package/bin/install.js +99 -6
- package/bin/state.js +136 -2
- package/package.json +4 -4
- package/tests/bin.test.sh +673 -0
- package/tests/hooks.test.sh +155 -25
- package/tests/state.test.sh +137 -0
- package/tests/statusline.test.sh +243 -0
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ Enter your team code when prompted. Get your code from Fawzi.
|
|
|
16
16
|
```bash
|
|
17
17
|
npx qualia-framework-v2 version # Check installed version + updates
|
|
18
18
|
npx qualia-framework-v2 update # Update to latest (remembers your code)
|
|
19
|
+
npx qualia-framework-v2 uninstall # Clean removal from ~/.claude/
|
|
19
20
|
```
|
|
20
21
|
|
|
21
22
|
## Usage
|
|
@@ -115,4 +116,8 @@ npx qualia-framework-v2 install
|
|
|
115
116
|
|
|
116
117
|
Stack: Next.js 16+, React 19, TypeScript, Supabase, Vercel.
|
|
117
118
|
|
|
119
|
+
## Changelog
|
|
120
|
+
|
|
121
|
+
See [CHANGELOG.md](./CHANGELOG.md) for the full version history.
|
|
122
|
+
|
|
118
123
|
Built by [Qualia Solutions](https://qualiasolutions.net) — Nicosia, Cyprus.
|
package/bin/cli.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
const {
|
|
3
|
+
const { spawnSync } = require("child_process");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const fs = require("fs");
|
|
6
|
+
const readline = require("readline");
|
|
6
7
|
|
|
7
8
|
const TEAL = "\x1b[38;2;0;206;209m";
|
|
8
9
|
const TG = "\x1b[38;2;0;170;175m";
|
|
@@ -57,10 +58,17 @@ function cmdVersion() {
|
|
|
57
58
|
|
|
58
59
|
// Check for updates
|
|
59
60
|
try {
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
// spawnSync with argv — no bash-only `2>/dev/null` redirect, no shell
|
|
62
|
+
// interpolation. stdio: "ignore" on stderr silences any npm warnings
|
|
63
|
+
// (offline, proxy, etc.) without a shell redirect. shell: true on
|
|
64
|
+
// Windows because `npm` is a .cmd shim that only resolves through cmd.
|
|
65
|
+
const r = spawnSync("npm", ["view", "qualia-framework-v2", "version"], {
|
|
66
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
67
|
+
shell: process.platform === "win32",
|
|
62
68
|
timeout: 5000,
|
|
63
|
-
|
|
69
|
+
encoding: "utf8",
|
|
70
|
+
});
|
|
71
|
+
const latest = (r.stdout || "").trim();
|
|
64
72
|
const semverGt = (a, b) => {
|
|
65
73
|
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
66
74
|
for (let i = 0; i < 3; i++) { if (pa[i] > pb[i]) return true; if (pa[i] < pb[i]) return false; }
|
|
@@ -94,7 +102,6 @@ function cmdUpdate() {
|
|
|
94
102
|
console.log("");
|
|
95
103
|
|
|
96
104
|
try {
|
|
97
|
-
const { spawnSync } = require("child_process");
|
|
98
105
|
const r = spawnSync("npx", ["qualia-framework-v2@latest", "install"], {
|
|
99
106
|
input: cfg.code + "\n",
|
|
100
107
|
stdio: ["pipe", "inherit", "inherit"],
|
|
@@ -113,6 +120,253 @@ function cmdUpdate() {
|
|
|
113
120
|
}
|
|
114
121
|
}
|
|
115
122
|
|
|
123
|
+
// ─── Uninstall ───────────────────────────────────────────
|
|
124
|
+
// Surgical removal of the Qualia Framework from ~/.claude/.
|
|
125
|
+
// Preserves CLAUDE.md (user may have customized it) and preserves any
|
|
126
|
+
// non-Qualia entries in settings.json (other hooks, user env vars, etc.).
|
|
127
|
+
// --yes / -y skips the confirmation prompt for scripted use.
|
|
128
|
+
|
|
129
|
+
// 8 Qualia hook filenames — only these are removed from ~/.claude/hooks/,
|
|
130
|
+
// any other hooks the user dropped in there are left alone.
|
|
131
|
+
const QUALIA_HOOK_FILES = [
|
|
132
|
+
"session-start.js",
|
|
133
|
+
"auto-update.js",
|
|
134
|
+
"branch-guard.js",
|
|
135
|
+
"pre-push.js",
|
|
136
|
+
"block-env-edit.js",
|
|
137
|
+
"migration-guard.js",
|
|
138
|
+
"pre-deploy-gate.js",
|
|
139
|
+
"pre-compact.js",
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
// 4 Qualia agents — only these are removed.
|
|
143
|
+
const QUALIA_AGENT_FILES = ["planner.md", "builder.md", "verifier.md", "qa-browser.md"];
|
|
144
|
+
|
|
145
|
+
// 3 Qualia bin scripts.
|
|
146
|
+
const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js"];
|
|
147
|
+
|
|
148
|
+
// 4 Qualia rules.
|
|
149
|
+
const QUALIA_RULE_FILES = ["security.md", "frontend.md", "design-reference.md", "deployment.md"];
|
|
150
|
+
|
|
151
|
+
function promptYesNo(question, defaultYes) {
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
154
|
+
const suffix = defaultYes ? " (Y/n)" : " (y/N)";
|
|
155
|
+
rl.question(` ${WHITE}${question}${RESET}${suffix} `, (answer) => {
|
|
156
|
+
rl.close();
|
|
157
|
+
const a = String(answer || "").trim().toLowerCase();
|
|
158
|
+
if (!a) return resolve(defaultYes);
|
|
159
|
+
resolve(a === "y" || a === "yes");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function safeUnlink(p, counters) {
|
|
165
|
+
try {
|
|
166
|
+
if (fs.existsSync(p)) {
|
|
167
|
+
fs.unlinkSync(p);
|
|
168
|
+
counters.filesRemoved++;
|
|
169
|
+
}
|
|
170
|
+
} catch (e) {
|
|
171
|
+
counters.errors.push(`${p}: ${e.message}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function safeRmDir(p, counters) {
|
|
176
|
+
try {
|
|
177
|
+
if (fs.existsSync(p)) {
|
|
178
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
179
|
+
counters.dirsRemoved++;
|
|
180
|
+
}
|
|
181
|
+
} catch (e) {
|
|
182
|
+
counters.errors.push(`${p}: ${e.message}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function cleanSettingsJson(counters) {
|
|
187
|
+
const settingsPath = path.join(CLAUDE_DIR, "settings.json");
|
|
188
|
+
if (!fs.existsSync(settingsPath)) return;
|
|
189
|
+
let settings;
|
|
190
|
+
try {
|
|
191
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
192
|
+
} catch (e) {
|
|
193
|
+
counters.errors.push(`settings.json: ${e.message}`);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Only remove entries that point at qualia paths. Leave everything else.
|
|
198
|
+
const isQualiaCommand = (cmd) =>
|
|
199
|
+
typeof cmd === "string" && (cmd.includes("qualia") || cmd.includes(".claude/hooks/") || cmd.includes(".claude/bin/"));
|
|
200
|
+
|
|
201
|
+
const filterHookArray = (arr) => {
|
|
202
|
+
if (!Array.isArray(arr)) return arr;
|
|
203
|
+
return arr
|
|
204
|
+
.map((entry) => {
|
|
205
|
+
if (!entry || !Array.isArray(entry.hooks)) return entry;
|
|
206
|
+
const hooks = entry.hooks.filter((h) => !isQualiaCommand(h && h.command));
|
|
207
|
+
return { ...entry, hooks };
|
|
208
|
+
})
|
|
209
|
+
.filter((entry) => Array.isArray(entry.hooks) && entry.hooks.length > 0);
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (settings.hooks && typeof settings.hooks === "object") {
|
|
213
|
+
for (const key of ["SessionStart", "PreToolUse", "PreCompact"]) {
|
|
214
|
+
if (settings.hooks[key]) {
|
|
215
|
+
const cleaned = filterHookArray(settings.hooks[key]);
|
|
216
|
+
if (cleaned && cleaned.length > 0) {
|
|
217
|
+
settings.hooks[key] = cleaned;
|
|
218
|
+
} else {
|
|
219
|
+
delete settings.hooks[key];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// If hooks is now empty, remove it entirely.
|
|
224
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Status line — only drop it if it points at our renderer.
|
|
228
|
+
if (settings.statusLine && typeof settings.statusLine === "object") {
|
|
229
|
+
const cmd = settings.statusLine.command || "";
|
|
230
|
+
if (isQualiaCommand(cmd) || cmd.includes("statusline.js") || cmd.includes("qualia-ui")) {
|
|
231
|
+
delete settings.statusLine;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Qualia-specific spinner overrides.
|
|
236
|
+
if (settings.spinnerVerbs) delete settings.spinnerVerbs;
|
|
237
|
+
if (settings.spinnerTipsOverride) delete settings.spinnerTipsOverride;
|
|
238
|
+
|
|
239
|
+
// Leave settings.env alone — the user may have other env vars in there.
|
|
240
|
+
|
|
241
|
+
try {
|
|
242
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
|
|
243
|
+
counters.settingsCleaned = true;
|
|
244
|
+
} catch (e) {
|
|
245
|
+
counters.errors.push(`settings.json write: ${e.message}`);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function cmdUninstall() {
|
|
250
|
+
banner();
|
|
251
|
+
|
|
252
|
+
const args = process.argv.slice(3);
|
|
253
|
+
const skipConfirm = args.includes("-y") || args.includes("--yes");
|
|
254
|
+
|
|
255
|
+
const cfg = readConfig();
|
|
256
|
+
console.log("");
|
|
257
|
+
if (cfg.installed_by) {
|
|
258
|
+
console.log(` ${DIM}User:${RESET} ${WHITE}${cfg.installed_by}${RESET} ${DIM}(${cfg.role || "?"})${RESET}`);
|
|
259
|
+
} else {
|
|
260
|
+
console.log(` ${DIM}No Qualia config found at${RESET} ${WHITE}${CONFIG_FILE}${RESET}`);
|
|
261
|
+
}
|
|
262
|
+
console.log("");
|
|
263
|
+
|
|
264
|
+
if (!skipConfirm) {
|
|
265
|
+
const confirm = await promptYesNo("Are you sure you want to uninstall the Qualia Framework?", false);
|
|
266
|
+
if (!confirm) {
|
|
267
|
+
console.log("");
|
|
268
|
+
console.log(` ${DIM}Aborted.${RESET}`);
|
|
269
|
+
console.log("");
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Preserve knowledge base by default.
|
|
275
|
+
let preserveKnowledge = true;
|
|
276
|
+
if (!skipConfirm) {
|
|
277
|
+
preserveKnowledge = await promptYesNo(
|
|
278
|
+
"Preserve knowledge base? (your learned patterns, fixes, client prefs)",
|
|
279
|
+
true
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log("");
|
|
284
|
+
console.log(` ${DIM}Removing framework files...${RESET}`);
|
|
285
|
+
console.log("");
|
|
286
|
+
|
|
287
|
+
const counters = { filesRemoved: 0, dirsRemoved: 0, settingsCleaned: false, errors: [] };
|
|
288
|
+
|
|
289
|
+
// Skills — any directory starting with "qualia" under ~/.claude/skills/.
|
|
290
|
+
const skillsDir = path.join(CLAUDE_DIR, "skills");
|
|
291
|
+
try {
|
|
292
|
+
if (fs.existsSync(skillsDir)) {
|
|
293
|
+
for (const name of fs.readdirSync(skillsDir)) {
|
|
294
|
+
if (name === "qualia" || name.startsWith("qualia-")) {
|
|
295
|
+
safeRmDir(path.join(skillsDir, name), counters);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch (e) {
|
|
300
|
+
counters.errors.push(`skills scan: ${e.message}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Agents — only the 4 Qualia ones.
|
|
304
|
+
for (const f of QUALIA_AGENT_FILES) {
|
|
305
|
+
safeUnlink(path.join(CLAUDE_DIR, "agents", f), counters);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Hooks — only the 8 Qualia ones.
|
|
309
|
+
for (const f of QUALIA_HOOK_FILES) {
|
|
310
|
+
safeUnlink(path.join(CLAUDE_DIR, "hooks", f), counters);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Bin scripts — only the 3 Qualia ones.
|
|
314
|
+
for (const f of QUALIA_BIN_FILES) {
|
|
315
|
+
safeUnlink(path.join(CLAUDE_DIR, "bin", f), counters);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Rules — all 4.
|
|
319
|
+
for (const f of QUALIA_RULE_FILES) {
|
|
320
|
+
safeUnlink(path.join(CLAUDE_DIR, "rules", f), counters);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Templates directory (entire).
|
|
324
|
+
safeRmDir(path.join(CLAUDE_DIR, "qualia-templates"), counters);
|
|
325
|
+
|
|
326
|
+
// Knowledge directory (optional preservation).
|
|
327
|
+
if (!preserveKnowledge) {
|
|
328
|
+
safeRmDir(path.join(CLAUDE_DIR, "knowledge"), counters);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Config + state files.
|
|
332
|
+
safeUnlink(path.join(CLAUDE_DIR, ".qualia-config.json"), counters);
|
|
333
|
+
safeUnlink(path.join(CLAUDE_DIR, ".qualia-last-update-check"), counters);
|
|
334
|
+
safeUnlink(path.join(CLAUDE_DIR, ".erp-api-key"), counters);
|
|
335
|
+
safeUnlink(path.join(CLAUDE_DIR, "qualia-guide.md"), counters);
|
|
336
|
+
|
|
337
|
+
// Clean settings.json surgically.
|
|
338
|
+
cleanSettingsJson(counters);
|
|
339
|
+
|
|
340
|
+
// Summary.
|
|
341
|
+
console.log("");
|
|
342
|
+
console.log(`${TEAL} ◆ Uninstall complete${RESET}`);
|
|
343
|
+
console.log(`${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
|
|
344
|
+
console.log(` ${DIM}Files removed:${RESET} ${WHITE}${counters.filesRemoved}${RESET}`);
|
|
345
|
+
console.log(` ${DIM}Directories removed:${RESET} ${WHITE}${counters.dirsRemoved}${RESET}`);
|
|
346
|
+
console.log(
|
|
347
|
+
` ${DIM}settings.json:${RESET} ${counters.settingsCleaned ? `${GREEN}cleaned ✓${RESET}` : `${DIM}not present${RESET}`}`
|
|
348
|
+
);
|
|
349
|
+
if (preserveKnowledge) {
|
|
350
|
+
console.log(` ${DIM}Knowledge base:${RESET} ${GREEN}preserved ✓${RESET}`);
|
|
351
|
+
} else {
|
|
352
|
+
console.log(` ${DIM}Knowledge base:${RESET} ${YELLOW}removed${RESET}`);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (counters.errors.length > 0) {
|
|
356
|
+
console.log("");
|
|
357
|
+
console.log(` ${YELLOW}${counters.errors.length} warning(s):${RESET}`);
|
|
358
|
+
for (const err of counters.errors.slice(0, 5)) {
|
|
359
|
+
console.log(` ${DIM}${err}${RESET}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
console.log("");
|
|
364
|
+
console.log(
|
|
365
|
+
` ${YELLOW}Manual step:${RESET} edit ${WHITE}~/.claude/CLAUDE.md${RESET} to remove the Qualia Framework section if desired.`
|
|
366
|
+
);
|
|
367
|
+
console.log("");
|
|
368
|
+
}
|
|
369
|
+
|
|
116
370
|
function cmdHelp() {
|
|
117
371
|
banner();
|
|
118
372
|
console.log("");
|
|
@@ -120,6 +374,7 @@ function cmdHelp() {
|
|
|
120
374
|
console.log(` npx qualia-framework-v2 ${TEAL}install${RESET} Install or reinstall the framework`);
|
|
121
375
|
console.log(` npx qualia-framework-v2 ${TEAL}update${RESET} Update to the latest version`);
|
|
122
376
|
console.log(` npx qualia-framework-v2 ${TEAL}version${RESET} Show installed version + check for updates`);
|
|
377
|
+
console.log(` npx qualia-framework-v2 ${TEAL}uninstall${RESET} Clean removal from ~/.claude/ (${DIM}-y to skip prompts${RESET})`);
|
|
123
378
|
console.log("");
|
|
124
379
|
console.log(` ${WHITE}After install:${RESET}`);
|
|
125
380
|
console.log(` ${TG}/qualia${RESET} What should I do next?`);
|
|
@@ -151,6 +406,13 @@ switch (cmd) {
|
|
|
151
406
|
case "upgrade":
|
|
152
407
|
cmdUpdate();
|
|
153
408
|
break;
|
|
409
|
+
case "uninstall":
|
|
410
|
+
case "remove":
|
|
411
|
+
cmdUninstall().catch((e) => {
|
|
412
|
+
console.error(`${RED} ✗ Uninstall failed: ${e.message}${RESET}`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
});
|
|
415
|
+
break;
|
|
154
416
|
default:
|
|
155
417
|
cmdHelp();
|
|
156
418
|
}
|
package/bin/install.js
CHANGED
|
@@ -80,14 +80,32 @@ function askCode() {
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// ─── Resolve team code (tolerates case + O/0 typo in suffix) ─
|
|
84
|
+
// Accepts "qs-fawzi-01", "QS-FAWZI-01", "QS-FAWZI-O1" (letter O in the
|
|
85
|
+
// numeric suffix), and returns the canonical key if found, else null.
|
|
86
|
+
// Only normalizes O→0 in the segment AFTER the last dash — "QS-MOAYAD-03"
|
|
87
|
+
// contains a real "O" in the name and must not be mangled.
|
|
88
|
+
function resolveTeamCode(input) {
|
|
89
|
+
const normalized = String(input || "").trim().toUpperCase();
|
|
90
|
+
if (TEAM[normalized]) return normalized;
|
|
91
|
+
const fuzzy = normalized.replace(
|
|
92
|
+
/-([^-]*)$/,
|
|
93
|
+
(_, suffix) => `-${suffix.replace(/O/g, "0")}`
|
|
94
|
+
);
|
|
95
|
+
if (TEAM[fuzzy]) return fuzzy;
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
// ─── Main ────────────────────────────────────────────────
|
|
84
100
|
async function main() {
|
|
85
|
-
const
|
|
86
|
-
const
|
|
101
|
+
const rawCode = await askCode();
|
|
102
|
+
const code = resolveTeamCode(rawCode);
|
|
103
|
+
const member = code ? TEAM[code] : null;
|
|
87
104
|
|
|
88
105
|
if (!member) {
|
|
89
106
|
console.log("");
|
|
90
|
-
log(`${RED}✗${RESET} Invalid code. Get your install code from Fawzi.`);
|
|
107
|
+
log(`${RED}✗${RESET} Invalid code: "${rawCode}". Get your install code from Fawzi.`);
|
|
108
|
+
log(`${DIM} Tip: codes use digit zero, not letter O. Format: QS-NAME-01${RESET}`);
|
|
91
109
|
console.log("");
|
|
92
110
|
process.exit(1);
|
|
93
111
|
}
|
|
@@ -248,9 +266,84 @@ async function main() {
|
|
|
248
266
|
const knowledgeDir = path.join(CLAUDE_DIR, "knowledge");
|
|
249
267
|
if (!fs.existsSync(knowledgeDir)) fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
250
268
|
const knowledgeFiles = {
|
|
251
|
-
"learned-patterns.md":
|
|
252
|
-
|
|
253
|
-
|
|
269
|
+
"learned-patterns.md": `# Learned Patterns
|
|
270
|
+
|
|
271
|
+
Patterns discovered across projects. Updated by \`/qualia-learn\` and manual notes.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## Cross-platform Node: always spawnSync with argv, never execSync with shell strings
|
|
276
|
+
**Why:** \`execSync(\\\`node \${path}/state.js check 2>/dev/null\\\`)\` breaks on Windows when the path contains spaces (common: \`C:\\\\Users\\\\John Doe\`) and the \`2>/dev/null\` redirect is bash-only. Windows cmd.exe tries to create \`\\\\dev\\\\null\` at drive root.
|
|
277
|
+
**How:** Use \`spawnSync(process.execPath, [path, "check"], { stdio: ["ignore","pipe","ignore"] })\`. Argv array is immune to path splitting; \`stdio: "ignore"\` silences stderr without shell redirection.
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Cross-platform stdin piping: spawnSync with input:, not bash <<< here-strings
|
|
282
|
+
**Why:** The \`<<<\` bash here-string works on bash + zsh but fails silently on Windows cmd.exe AND on Debian/Ubuntu where \`/bin/sh\` is dash (no \`<<<\` support).
|
|
283
|
+
**How:** \`spawnSync("npx", ["cmd"], { input: "data\\\\n", stdio: ["pipe","inherit","inherit"], shell: process.platform === "win32" })\`. The \`input:\` option pipes stdin directly. \`shell: process.platform === "win32"\` is required because npm/npx are \`.cmd\` shims on Windows that only resolve through a shell.
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## Fresh-context isolation beats shared-context compression
|
|
288
|
+
**Why:** Claude's output quality degrades as context fills. A single massive context doing plan + build + verify hits the degradation curve on the later tasks.
|
|
289
|
+
**How:** Spawn separate subagents for planner / builder (per task) / verifier. Each gets fresh context. Task 50 gets the same quality as task 1. Cost: PROJECT.md + STATE.md get re-loaded into each subagent context, but the quality win dominates.
|
|
290
|
+
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
## Goal-backward verification beats task-completion tracking
|
|
294
|
+
**Why:** A task "create chat component" can be marked complete with a placeholder file. The task ran; the goal didn't.
|
|
295
|
+
**How:** For each phase success criterion, do a 3-level check: (1) what must be TRUE, (2) what files/functions must EXIST and be substantive (not stubs), (3) what must be CONNECTED (imported and called). Grep the codebase. Never trust summaries.
|
|
296
|
+
`,
|
|
297
|
+
"common-fixes.md": `# Common Fixes
|
|
298
|
+
|
|
299
|
+
Recurring issues and their solutions.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
303
|
+
## Install code "Invalid" — user typed letter O instead of digit 0
|
|
304
|
+
**Symptom:** \`npx qualia-framework-v2 install\` rejects \`QS-NAME-O1\` (letter O in suffix).
|
|
305
|
+
**Cause:** Team codes use digit zero (\`-01\`, \`-02\`, etc.), not letter O.
|
|
306
|
+
**Fix:** Since v2.8.1, install.js auto-normalizes: \`QS-FAWZI-O1\` → \`QS-FAWZI-01\`. The normalization only touches the segment after the last dash, so \`QS-MOAYAD-03\` (real O in name) is preserved.
|
|
307
|
+
**Framework version:** Fixed in v2.8.1.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Windows banner shows "No project detected" inside a real project
|
|
312
|
+
**Symptom:** The session-start banner from qualia-ui.js displays the router panel but without phase/status, even in a project with \`.planning/\`.
|
|
313
|
+
**Cause:** Before v2.8.0, \`qualia-ui.js\` called state.js via \`execSync(\\\`node \${path} check 2>/dev/null\\\`)\`. Windows cmd.exe couldn't parse the \`2>/dev/null\` redirect and/or split the path on spaces in the username.
|
|
314
|
+
**Fix:** v2.8.0 switched to \`spawnSync(process.execPath, [statePath, "check"], { stdio: ["ignore","pipe","ignore"] })\`. Argv array + silent stdio = cross-platform safe.
|
|
315
|
+
**Framework version:** Fixed in v2.8.0.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## \`npx qualia-framework-v2 update\` fails on Windows or Ubuntu
|
|
320
|
+
**Symptom:** Manual update command fails silently or with a shell parse error on Windows and Debian/Ubuntu.
|
|
321
|
+
**Cause:** Before v2.8.0, cli.js cmdUpdate used \`execSync(\\\`npx ... install <<< "\${code}"\\\`, { shell: true })\`. The \`<<<\` here-string is bash-only; cmd.exe doesn't understand it, and \`/bin/sh\` on Debian/Ubuntu is \`dash\` which also lacks it.
|
|
322
|
+
**Fix:** v2.8.0 replaced with \`spawnSync("npx", [...], { input: code + "\\\\n", shell: process.platform === "win32" })\`. Uses stdin pipe instead of here-string.
|
|
323
|
+
**Framework version:** Fixed in v2.8.0.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Pre-deploy gate false-positive on Next.js Server Components using service_role
|
|
328
|
+
**Symptom:** \`/qualia-ship\` is blocked with "service_role found in client code" for a file that's actually a Server Component (runs server-side only).
|
|
329
|
+
**Cause:** pre-deploy-gate.js skips files matching \`.server.\` filename pattern OR \`server/\` directory path. If the Server Component is at \`app/admin/page.tsx\` (no .server. marker, not in a server/ dir), the scan flags it.
|
|
330
|
+
**Workaround:** Rename to \`.server.tsx\` OR move to a \`server/\` subdirectory OR extract the service_role usage into a helper in \`lib/server/\`.
|
|
331
|
+
**Framework version:** Known issue as of v2.8.1; better heuristic planned for v3.0.
|
|
332
|
+
`,
|
|
333
|
+
"client-prefs.md": `# Client Preferences
|
|
334
|
+
|
|
335
|
+
Client-specific preferences, design choices, and requirements. Loaded by \`/qualia-new\` when starting a project for a known client.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## Example Client (template)
|
|
340
|
+
**Industry:** {e.g., fintech, healthcare, SaaS}
|
|
341
|
+
**Contact:** {email}
|
|
342
|
+
**Design:** {dark-bold | clean-minimal | colorful-playful | corporate-professional}
|
|
343
|
+
**Stack preferences:** {anything non-default}
|
|
344
|
+
**Hard constraints:** {things they've explicitly said no to}
|
|
345
|
+
**Source of notes:** {date or conversation reference}
|
|
346
|
+
`,
|
|
254
347
|
};
|
|
255
348
|
for (const [name, defaultContent] of Object.entries(knowledgeFiles)) {
|
|
256
349
|
const dest = path.join(knowledgeDir, name);
|
package/bin/state.js
CHANGED
|
@@ -51,19 +51,54 @@ function readState() {
|
|
|
51
51
|
// ─── STATE.md Parser ─────────────────────────────────────
|
|
52
52
|
function parseStateMd(content) {
|
|
53
53
|
if (!content) return null;
|
|
54
|
+
const schema_errors = [];
|
|
54
55
|
const get = (prefix) => {
|
|
55
56
|
const m = content.match(new RegExp(`^${prefix}:\\s*(.+)$`, "m"));
|
|
56
57
|
return m ? m[1].trim() : "";
|
|
57
58
|
};
|
|
59
|
+
const hasField = (prefix) =>
|
|
60
|
+
new RegExp(`^${prefix}:\\s*`, "m").test(content);
|
|
61
|
+
|
|
58
62
|
const phaseMatch = content.match(
|
|
59
63
|
/^Phase:\s*(\d+)\s+of\s+(\d+)\s*[—-]\s*(.+)$/m
|
|
60
64
|
);
|
|
65
|
+
if (!phaseMatch) {
|
|
66
|
+
schema_errors.push({
|
|
67
|
+
field: "phase_header",
|
|
68
|
+
message: 'Missing or malformed "Phase: N of M — Name" header',
|
|
69
|
+
severity: "error",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Status field presence (independent of value)
|
|
74
|
+
if (!hasField("Status")) {
|
|
75
|
+
schema_errors.push({
|
|
76
|
+
field: "status_field",
|
|
77
|
+
message: "Missing Status: field",
|
|
78
|
+
severity: "warning",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
61
82
|
// Parse roadmap table
|
|
62
83
|
const phases = [];
|
|
84
|
+
const tableHeaderRe = /\| # \| Phase \| Goal \| Status \|/;
|
|
63
85
|
const tableMatch = content.match(
|
|
64
86
|
/\| # \| Phase \| Goal \| Status \|\n\|[-|]+\|\n([\s\S]*?)(?=\n##|\n$|$)/
|
|
65
87
|
);
|
|
66
|
-
if (
|
|
88
|
+
if (!tableHeaderRe.test(content)) {
|
|
89
|
+
schema_errors.push({
|
|
90
|
+
field: "roadmap_table",
|
|
91
|
+
message: "Roadmap table header not found",
|
|
92
|
+
severity: "error",
|
|
93
|
+
});
|
|
94
|
+
} else if (!tableMatch) {
|
|
95
|
+
// Header is there but the separator row or body is malformed
|
|
96
|
+
schema_errors.push({
|
|
97
|
+
field: "roadmap_table",
|
|
98
|
+
message: "Roadmap table is malformed (missing separator row or body)",
|
|
99
|
+
severity: "error",
|
|
100
|
+
});
|
|
101
|
+
} else {
|
|
67
102
|
for (const row of tableMatch[1].trim().split("\n")) {
|
|
68
103
|
const cols = row.split("|").map((c) => c.trim()).filter(Boolean);
|
|
69
104
|
if (cols.length >= 4) {
|
|
@@ -76,6 +111,19 @@ function parseStateMd(content) {
|
|
|
76
111
|
}
|
|
77
112
|
}
|
|
78
113
|
}
|
|
114
|
+
|
|
115
|
+
// Row count vs header "of M"
|
|
116
|
+
if (phaseMatch) {
|
|
117
|
+
const declaredTotal = parseInt(phaseMatch[2]);
|
|
118
|
+
if (phases.length && phases.length !== declaredTotal) {
|
|
119
|
+
schema_errors.push({
|
|
120
|
+
field: "roadmap_rows",
|
|
121
|
+
message: `Expected ${declaredTotal} phases in roadmap, found ${phases.length}`,
|
|
122
|
+
severity: "warning",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
79
127
|
return {
|
|
80
128
|
phase: phaseMatch ? parseInt(phaseMatch[1]) : 1,
|
|
81
129
|
total_phases: phaseMatch ? parseInt(phaseMatch[2]) : phases.length || 1,
|
|
@@ -83,6 +131,7 @@ function parseStateMd(content) {
|
|
|
83
131
|
status: get("Status").toLowerCase().replace(/\s+/g, "_") || "setup",
|
|
84
132
|
assigned_to: get("Assigned to") || "",
|
|
85
133
|
phases,
|
|
134
|
+
schema_errors,
|
|
86
135
|
};
|
|
87
136
|
}
|
|
88
137
|
|
|
@@ -254,6 +303,7 @@ function cmdCheck(opts) {
|
|
|
254
303
|
s.total_phases,
|
|
255
304
|
t.verification
|
|
256
305
|
),
|
|
306
|
+
schema_errors: s.schema_errors && s.schema_errors.length ? s.schema_errors : undefined,
|
|
257
307
|
});
|
|
258
308
|
}
|
|
259
309
|
|
|
@@ -269,6 +319,16 @@ function cmdTransition(opts) {
|
|
|
269
319
|
);
|
|
270
320
|
}
|
|
271
321
|
|
|
322
|
+
// Refuse transitions if STATE.md has schema errors (severity=error)
|
|
323
|
+
if (s.schema_errors && s.schema_errors.some((e) => e.severity === "error")) {
|
|
324
|
+
return output(
|
|
325
|
+
fail(
|
|
326
|
+
"STATE_SCHEMA_ERROR",
|
|
327
|
+
"STATE.md is malformed. Run `node state.js check` to see errors. Consider `state.js fix` to rewrite canonically."
|
|
328
|
+
)
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
272
332
|
// Special: note/activity (no status change)
|
|
273
333
|
if (target === "note" || target === "activity") {
|
|
274
334
|
const now = new Date().toISOString().split("T")[0];
|
|
@@ -466,6 +526,77 @@ function cmdInit(opts) {
|
|
|
466
526
|
});
|
|
467
527
|
}
|
|
468
528
|
|
|
529
|
+
function cmdFix(opts) {
|
|
530
|
+
const raw = readState();
|
|
531
|
+
const t = readTracking();
|
|
532
|
+
if (!raw && !t) {
|
|
533
|
+
return output(
|
|
534
|
+
fail("NO_PROJECT", "No .planning/ found. Run /qualia-new.")
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
const parsed = parseStateMd(raw) || {
|
|
538
|
+
phase: 1,
|
|
539
|
+
total_phases: 1,
|
|
540
|
+
phase_name: "",
|
|
541
|
+
status: "setup",
|
|
542
|
+
assigned_to: "",
|
|
543
|
+
phases: [],
|
|
544
|
+
schema_errors: [
|
|
545
|
+
{ field: "content", message: "STATE.md missing or empty", severity: "error" },
|
|
546
|
+
],
|
|
547
|
+
};
|
|
548
|
+
const previousErrors = (parsed.schema_errors || []).length;
|
|
549
|
+
|
|
550
|
+
// Prefer tracking.json values when parsed fields are defaulted/missing
|
|
551
|
+
const tr = t || {};
|
|
552
|
+
const totalPhases =
|
|
553
|
+
parseInt(tr.total_phases) || parsed.total_phases || parsed.phases.length || 1;
|
|
554
|
+
const phaseNum = parseInt(tr.phase) || parsed.phase || 1;
|
|
555
|
+
const phaseName =
|
|
556
|
+
(parsed.phase_name && parsed.phase_name.trim()) ||
|
|
557
|
+
tr.phase_name ||
|
|
558
|
+
`Phase ${phaseNum}`;
|
|
559
|
+
const status = parsed.status || tr.status || "setup";
|
|
560
|
+
const assignedTo = parsed.assigned_to || tr.assigned_to || "";
|
|
561
|
+
|
|
562
|
+
// Build a phases array of the right length
|
|
563
|
+
const phases = [];
|
|
564
|
+
for (let i = 0; i < totalPhases; i++) {
|
|
565
|
+
const existing = parsed.phases[i];
|
|
566
|
+
phases.push({
|
|
567
|
+
num: i + 1,
|
|
568
|
+
name: existing?.name || `Phase ${i + 1}`,
|
|
569
|
+
goal: existing?.goal || "TBD",
|
|
570
|
+
status: existing?.status || (i === 0 ? "ready" : "—"),
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
const s = {
|
|
575
|
+
phase: phaseNum,
|
|
576
|
+
total_phases: totalPhases,
|
|
577
|
+
phase_name: phaseName,
|
|
578
|
+
status,
|
|
579
|
+
assigned_to: assignedTo,
|
|
580
|
+
last_activity: "STATE.md repaired by state.js fix",
|
|
581
|
+
phases,
|
|
582
|
+
blockers: "None.",
|
|
583
|
+
resume: "—",
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
writeStateMd(s);
|
|
588
|
+
} catch (e) {
|
|
589
|
+
return output(fail("WRITE_ERROR", e.message));
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
output({
|
|
593
|
+
ok: true,
|
|
594
|
+
action: "fix",
|
|
595
|
+
previous_errors: previousErrors,
|
|
596
|
+
fixed: true,
|
|
597
|
+
});
|
|
598
|
+
}
|
|
599
|
+
|
|
469
600
|
// ─── Output ──────────────────────────────────────────────
|
|
470
601
|
function output(obj) {
|
|
471
602
|
console.log(JSON.stringify(obj, null, 2));
|
|
@@ -486,11 +617,14 @@ switch (cmd) {
|
|
|
486
617
|
case "init":
|
|
487
618
|
cmdInit(opts);
|
|
488
619
|
break;
|
|
620
|
+
case "fix":
|
|
621
|
+
cmdFix(opts);
|
|
622
|
+
break;
|
|
489
623
|
default:
|
|
490
624
|
output(
|
|
491
625
|
fail(
|
|
492
626
|
"UNKNOWN_COMMAND",
|
|
493
|
-
`Usage: state.js <check|transition|init> [--options]`
|
|
627
|
+
`Usage: state.js <check|transition|init|fix> [--options]`
|
|
494
628
|
)
|
|
495
629
|
);
|
|
496
630
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qualia-framework-v2",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "Claude Code workflow framework by Qualia Solutions. Plan, build, verify, ship.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"qualia-framework-v2": "./bin/cli.js"
|
|
@@ -19,11 +19,11 @@
|
|
|
19
19
|
"license": "MIT",
|
|
20
20
|
"repository": {
|
|
21
21
|
"type": "git",
|
|
22
|
-
"url": "https://github.com/
|
|
22
|
+
"url": "git+https://github.com/Qualiasolutions/qualia-framework-v2.git"
|
|
23
23
|
},
|
|
24
|
-
"homepage": "https://github.com/
|
|
24
|
+
"homepage": "https://github.com/Qualiasolutions/qualia-framework-v2#readme",
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "bash tests/hooks.test.sh && bash tests/state.test.sh"
|
|
26
|
+
"test": "bash tests/hooks.test.sh && bash tests/state.test.sh && bash tests/bin.test.sh && bash tests/statusline.test.sh"
|
|
27
27
|
},
|
|
28
28
|
"files": [
|
|
29
29
|
"bin/",
|