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 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 { execSync } = require("child_process");
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
- const latest = execSync("npm view qualia-framework-v2 version 2>/dev/null", {
61
- encoding: "utf8",
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
- }).trim();
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 code = await askCode();
86
- const member = TEAM[code];
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": "# Learned Patterns\n\nPatterns discovered across projects. Updated by `/qualia-learn` and manual notes.\n",
252
- "common-fixes.md": "# Common Fixes\n\nRecurring issues and their solutions.\n",
253
- "client-prefs.md": "# Client Preferences\n\nClient-specific preferences, design choices, and requirements.\n",
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 (tableMatch) {
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.8.0",
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/qualia-solutions/qualia-framework-v2"
22
+ "url": "git+https://github.com/Qualiasolutions/qualia-framework-v2.git"
23
23
  },
24
- "homepage": "https://github.com/qualia-solutions/qualia-framework-v2#readme",
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/",