qualia-framework-v2 2.9.0 → 3.0.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/bin/install.js CHANGED
@@ -13,8 +13,11 @@ const YELLOW = "\x1b[38;2;234;179;8m";
13
13
  const RED = "\x1b[38;2;239;68;68m";
14
14
  const RESET = "\x1b[0m";
15
15
 
16
+ const CLAUDE_DIR = path.join(require("os").homedir(), ".claude");
17
+ const FRAMEWORK_DIR = path.resolve(__dirname, "..");
18
+
16
19
  // ─── Team codes ──────────────────────────────────────────
17
- const TEAM = {
20
+ const DEFAULT_TEAM = {
18
21
  "QS-FAWZI-01": {
19
22
  name: "Fawzi Goussous",
20
23
  role: "OWNER",
@@ -42,8 +45,21 @@ const TEAM = {
42
45
  },
43
46
  };
44
47
 
45
- const CLAUDE_DIR = path.join(require("os").homedir(), ".claude");
46
- const FRAMEWORK_DIR = path.resolve(__dirname, "..");
48
+ // Load team from external file, fall back to embedded defaults.
49
+ function loadTeam() {
50
+ const teamFile = path.join(CLAUDE_DIR, ".qualia-team.json");
51
+ try {
52
+ if (fs.existsSync(teamFile)) {
53
+ const external = JSON.parse(fs.readFileSync(teamFile, "utf8"));
54
+ if (external && typeof external === "object" && Object.keys(external).length > 0) {
55
+ return external;
56
+ }
57
+ }
58
+ } catch {}
59
+ return DEFAULT_TEAM;
60
+ }
61
+
62
+ const TEAM = loadTeam();
47
63
 
48
64
  let installed = 0;
49
65
  let errors = 0;
@@ -70,7 +86,7 @@ function askCode() {
70
86
  return new Promise((resolve) => {
71
87
  const rl = createInterface({ input: process.stdin, output: process.stdout });
72
88
  console.log("");
73
- console.log(`${TEAL} Qualia Framework v2${RESET}`);
89
+ console.log(`${TEAL} Qualia Framework v2${RESET}`);
74
90
  console.log(`${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
75
91
  console.log("");
76
92
  rl.question(` ${WHITE}Enter install code:${RESET} `, (answer) => {
@@ -185,16 +201,6 @@ async function main() {
185
201
  }
186
202
  }
187
203
 
188
- // ─── Status line ───────────────────────────────────────
189
- log(`${WHITE}Status line${RESET}`);
190
- try {
191
- const slDest = path.join(CLAUDE_DIR, "bin", "statusline.js");
192
- copy(path.join(FRAMEWORK_DIR, "bin", "statusline.js"), slDest);
193
- ok("statusline.js");
194
- } catch (e) {
195
- warn(`statusline.js — ${e.message}`);
196
- }
197
-
198
204
  // ─── Templates ─────────────────────────────────────────
199
205
  log(`${WHITE}Templates${RESET}`);
200
206
  const tmplDir = path.join(FRAMEWORK_DIR, "templates");
@@ -363,6 +369,11 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
363
369
  role: member.role,
364
370
  version: require("../package.json").version,
365
371
  installed_at: new Date().toISOString().split("T")[0],
372
+ erp: {
373
+ enabled: true,
374
+ url: "https://portal.qualiasolutions.net",
375
+ api_key_file: ".erp-api-key",
376
+ },
366
377
  };
367
378
  fs.writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
368
379
 
@@ -425,16 +436,16 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
425
436
  settings.spinnerTipsOverride = {
426
437
  excludeDefault: true,
427
438
  tips: [
428
- " Lost? Type /qualia for the next step",
429
- " Small fix? Use /qualia-quick to skip planning",
430
- " End of day? /qualia-report before you clock out",
431
- " Context isolation: every task gets a fresh AI brain",
432
- " The verifier doesn't trust claims — it greps the code",
433
- " Plans are prompts — the plan IS what the builder reads",
434
- " Feature branches only — never push to main",
435
- " Read before write — no exceptions",
436
- " MVP first — build what's asked, nothing extra",
437
- " tracking.json syncs to ERP on every push",
439
+ " Lost? Type /qualia for the next step",
440
+ " Small fix? Use /qualia-quick to skip planning",
441
+ " End of day? /qualia-report before you clock out",
442
+ " Context isolation: every task gets a fresh AI brain",
443
+ " The verifier doesn't trust claims — it greps the code",
444
+ " Plans are prompts — the plan IS what the builder reads",
445
+ " Feature branches only — never push to main",
446
+ " Read before write — no exceptions",
447
+ " MVP first — build what's asked, nothing extra",
448
+ " tracking.json syncs to ERP on every push",
438
449
  ],
439
450
  };
440
451
 
@@ -469,22 +480,22 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
469
480
  type: "command",
470
481
  if: "Bash(git push*)",
471
482
  command: nodeCmd("branch-guard.js"),
472
- timeout: 10,
473
- statusMessage: " Checking branch permissions...",
483
+ timeout: 5,
484
+ statusMessage: " Checking branch permissions...",
474
485
  },
475
486
  {
476
487
  type: "command",
477
488
  if: "Bash(git push*)",
478
489
  command: nodeCmd("pre-push.js"),
479
490
  timeout: 15,
480
- statusMessage: " Syncing tracking...",
491
+ statusMessage: " Syncing tracking...",
481
492
  },
482
493
  {
483
494
  type: "command",
484
495
  if: "Bash(vercel --prod*)",
485
496
  command: nodeCmd("pre-deploy-gate.js"),
486
497
  timeout: 180,
487
- statusMessage: " Running quality gates...",
498
+ statusMessage: " Running quality gates...",
488
499
  },
489
500
  ],
490
501
  },
@@ -493,17 +504,16 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
493
504
  hooks: [
494
505
  {
495
506
  type: "command",
496
- if: "Edit(*.env*)|Write(*.env*)",
497
507
  command: nodeCmd("block-env-edit.js"),
498
508
  timeout: 5,
499
- statusMessage: " Checking file permissions...",
509
+ statusMessage: " Checking file permissions...",
500
510
  },
501
511
  {
502
512
  type: "command",
503
513
  if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)",
504
514
  command: nodeCmd("migration-guard.js"),
505
515
  timeout: 10,
506
- statusMessage: " Checking migration safety...",
516
+ statusMessage: " Checking migration safety...",
507
517
  },
508
518
  ],
509
519
  },
@@ -516,39 +526,34 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
516
526
  type: "command",
517
527
  command: nodeCmd("pre-compact.js"),
518
528
  timeout: 15,
519
- statusMessage: " Saving state...",
529
+ statusMessage: " Saving state...",
520
530
  },
521
531
  ],
522
532
  },
523
533
  ],
524
534
  };
525
535
 
526
- // Permissions
536
+ // Permissions — no restrictions on env files or branches.
537
+ // Everyone can read/write .env, push to main.
527
538
  if (!settings.permissions) settings.permissions = {};
528
539
  if (!settings.permissions.allow) settings.permissions.allow = [];
529
- if (!settings.permissions.deny) {
530
- settings.permissions.deny = [
531
- "Read(./.env)",
532
- "Read(./.env.*)",
533
- "Read(./secrets/**)",
534
- ];
535
- }
540
+ if (!settings.permissions.deny) settings.permissions.deny = [];
536
541
 
537
542
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
538
543
 
539
- ok("Hooks: session-start, auto-update, branch-guard, pre-push, env-block, migration-guard, deploy-gate, pre-compact");
544
+ ok("Hooks: session-start, auto-update, branch-guard, pre-push, block-env-edit, migration-guard, deploy-gate, pre-compact");
540
545
  ok("Status line + spinner configured");
541
546
  ok("Environment variables + permissions");
542
547
 
543
548
  // ─── Summary ───────────────────────────────────────────
544
549
  console.log("");
545
- console.log(`${TEAL} Installed ✓${RESET}`);
550
+ console.log(`${TEAL} Installed ✓${RESET}`);
546
551
  console.log(`${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
547
552
  console.log(` ${WHITE}${member.name}${RESET} ${DIM}(${member.role})${RESET}`);
548
553
  console.log(` Skills: ${WHITE}${skills.length}${RESET}`);
549
554
  const agentCount = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')).length;
550
555
  console.log(` Agents: ${WHITE}${agentCount}${RESET} ${DIM}(planner, builder, verifier, qa-browser)${RESET}`);
551
- console.log(` Hooks: ${WHITE}8${RESET} ${DIM}(session-start, auto-update, branch-guard, pre-push, env-block, migration-guard, deploy-gate, pre-compact)${RESET}`);
556
+ console.log(` Hooks: ${WHITE}8${RESET} ${DIM}(session-start, auto-update, branch-guard, pre-push, block-env-edit, migration-guard, deploy-gate, pre-compact)${RESET}`);
552
557
  console.log(` Rules: ${WHITE}${fs.readdirSync(rulesDir).length}${RESET} ${DIM}(security, frontend, design-reference, deployment)${RESET}`);
553
558
  console.log(` Scripts: ${WHITE}3${RESET} ${DIM}(state.js, qualia-ui.js, statusline.js)${RESET}`);
554
559
  console.log(` Knowledge: ${WHITE}3${RESET} ${DIM}(patterns, fixes, client prefs)${RESET}`);
package/bin/qualia-ui.js CHANGED
@@ -40,25 +40,25 @@ const RULE_DIM = `${DIM2}${RULE}${RESET}`;
40
40
 
41
41
  // ─── Action Labels ───────────────────────────────────────
42
42
  const ACTIONS = {
43
- router: { label: "SMART ROUTER", glyph: "" },
44
- new: { label: "NEW PROJECT", glyph: "" },
45
- plan: { label: "PLANNING", glyph: "" },
46
- build: { label: "BUILDING", glyph: "" },
47
- verify: { label: "VERIFYING", glyph: "" },
48
- polish: { label: "POLISHING", glyph: "" },
49
- ship: { label: "SHIPPING", glyph: "" },
50
- handoff: { label: "HANDING OFF", glyph: "" },
51
- report: { label: "SESSION REPORT", glyph: "" },
52
- debug: { label: "DEBUGGING", glyph: "" },
53
- learn: { label: "LEARNING", glyph: "" },
54
- pause: { label: "PAUSING", glyph: "" },
55
- resume: { label: "RESUMING", glyph: "" },
56
- review: { label: "REVIEW", glyph: "" },
57
- design: { label: "DESIGN PASS", glyph: "" },
58
- quick: { label: "QUICK FIX", glyph: "" },
59
- task: { label: "TASK", glyph: "" },
60
- "skill-new": { label: "NEW SKILL", glyph: "" },
61
- gaps: { label: "GAP CLOSURE", glyph: "" },
43
+ router: { label: "SMART ROUTER", glyph: "" },
44
+ new: { label: "NEW PROJECT", glyph: "" },
45
+ plan: { label: "PLANNING", glyph: "" },
46
+ build: { label: "BUILDING", glyph: "" },
47
+ verify: { label: "VERIFYING", glyph: "" },
48
+ polish: { label: "POLISHING", glyph: "" },
49
+ ship: { label: "SHIPPING", glyph: "" },
50
+ handoff: { label: "HANDING OFF", glyph: "" },
51
+ report: { label: "SESSION REPORT", glyph: "" },
52
+ debug: { label: "DEBUGGING", glyph: "" },
53
+ learn: { label: "LEARNING", glyph: "" },
54
+ pause: { label: "PAUSING", glyph: "" },
55
+ resume: { label: "RESUMING", glyph: "" },
56
+ review: { label: "REVIEW", glyph: "" },
57
+ design: { label: "DESIGN PASS", glyph: "" },
58
+ quick: { label: "QUICK FIX", glyph: "" },
59
+ task: { label: "TASK", glyph: "" },
60
+ "skill-new": { label: "NEW SKILL", glyph: "" },
61
+ gaps: { label: "GAP CLOSURE", glyph: "" },
62
62
  };
63
63
 
64
64
  // ─── State Reading ───────────────────────────────────────
@@ -126,7 +126,7 @@ function pad(str, width) {
126
126
 
127
127
  // ─── Commands ────────────────────────────────────────────
128
128
  function cmdBanner(action, phase, subtitle) {
129
- const spec = ACTIONS[action] || { label: (action || "qualia").toUpperCase(), glyph: "" };
129
+ const spec = ACTIONS[action] || { label: (action || "qualia").toUpperCase(), glyph: "" };
130
130
  const state = readState();
131
131
  const config = readConfig();
132
132
  const project = projectName();
@@ -136,7 +136,7 @@ function cmdBanner(action, phase, subtitle) {
136
136
  : spec.label;
137
137
 
138
138
  console.log("");
139
- console.log(` ${TEAL}${BOLD}${spec.glyph}${RESET} ${WHITE}${BOLD}QUALIA${RESET} ${DIM}►${RESET} ${WHITE}${title}${RESET}`);
139
+ console.log(` ${TEAL}${BOLD}${spec.glyph}${RESET} ${WHITE}${BOLD}QUALIA${RESET} ${DIM}▸${RESET} ${WHITE}${title}${RESET}`);
140
140
  console.log(` ${RULE_DIM}`);
141
141
 
142
142
  // Context panel
@@ -159,7 +159,7 @@ function cmdBanner(action, phase, subtitle) {
159
159
  const bar = progressBar(state.phase, state.total_phases);
160
160
  if (bar) console.log(` ${pad(DIM + "Progress" + RESET, 20)}${bar}`);
161
161
  if (state.gap_cycles > 0) {
162
- console.log(` ${pad(DIM + "Gap cycles" + RESET, 20)}${YELLOW}${state.gap_cycles}/2${RESET}`);
162
+ console.log(` ${pad(DIM + "Gap cycles" + RESET, 20)}${YELLOW}${state.gap_cycles}/${state.gap_cycle_limit || 2}${RESET}`);
163
163
  }
164
164
  }
165
165
 
@@ -218,7 +218,7 @@ function cmdInfo(msg) {
218
218
  function cmdSpawn(agent, desc) {
219
219
  const name = agent || "agent";
220
220
  const d = desc ? ` ${DIM}— ${desc}${RESET}` : "";
221
- console.log(` ${TEAL}⟐${RESET} ${WHITE}Spawning${RESET} ${TEAL}${name}${RESET}${d}`);
221
+ console.log(` ${TEAL}⬡${RESET} ${WHITE}Spawning${RESET} ${TEAL}${name}${RESET}${d}`);
222
222
  }
223
223
 
224
224
  function cmdWave(num, total, taskCount) {
@@ -226,7 +226,7 @@ function cmdWave(num, total, taskCount) {
226
226
  const n = parseInt(num) || 0;
227
227
  const t = parseInt(total) || 0;
228
228
  const c = parseInt(taskCount) || 0;
229
- console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}Wave ${n}/${t}${RESET} ${DIM}(${c} ${c === 1 ? "task" : "tasks"}, parallel)${RESET}`);
229
+ console.log(` ${TEAL}»${RESET} ${WHITE}${BOLD}Wave ${n}/${t}${RESET} ${DIM}(${c} ${c === 1 ? "task" : "tasks"}, parallel)${RESET}`);
230
230
  }
231
231
 
232
232
  function cmdTask(num, title) {
@@ -241,16 +241,16 @@ function cmdDone(num, title, commit) {
241
241
  function cmdNext(cmd) {
242
242
  if (!cmd) return;
243
243
  console.log("");
244
- console.log(` ${TEAL}→${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${cmd}${RESET}`);
244
+ console.log(` ${TEAL}⟶${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${cmd}${RESET}`);
245
245
  console.log("");
246
246
  }
247
247
 
248
248
  function cmdEnd(status, nextCmd) {
249
249
  console.log("");
250
- console.log(` ${TEAL}${BOLD}◆${RESET} ${WHITE}${BOLD}${status || "DONE"}${RESET}`);
250
+ console.log(` ${TEAL}${BOLD}⬢${RESET} ${WHITE}${BOLD}${status || "DONE"}${RESET}`);
251
251
  console.log(` ${RULE_DIM}`);
252
252
  if (nextCmd) {
253
- console.log(` ${TEAL}→${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${nextCmd}${RESET}`);
253
+ console.log(` ${TEAL}⟶${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${nextCmd}${RESET}`);
254
254
  }
255
255
  console.log("");
256
256
  }
package/bin/state.js CHANGED
@@ -186,6 +186,25 @@ const VALID_FROM = {
186
186
  done: ["handed_off"],
187
187
  };
188
188
 
189
+ // ─── Configurable Gap Cycle Limit ────────────────────────
190
+ function getGapCycleLimit() {
191
+ // Priority: tracking.json.gap_cycle_limit > PROJECT.md > default (2)
192
+ try {
193
+ const t = readTracking();
194
+ if (t && typeof t.gap_cycle_limit === "number" && t.gap_cycle_limit > 0) {
195
+ return t.gap_cycle_limit;
196
+ }
197
+ } catch {}
198
+
199
+ try {
200
+ const projectMd = fs.readFileSync(path.join(PLANNING, "PROJECT.md"), "utf8");
201
+ const match = projectMd.match(/^gap_cycle_limit:\s*(\d+)/m);
202
+ if (match) return parseInt(match[1]);
203
+ } catch {}
204
+
205
+ return 2; // default
206
+ }
207
+
189
208
  function checkPreconditions(current, target, opts) {
190
209
  const phase = parseInt(opts.phase) || current.phase;
191
210
 
@@ -207,6 +226,14 @@ function checkPreconditions(current, target, opts) {
207
226
  const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
208
227
  if (!fs.existsSync(planFile))
209
228
  return fail("MISSING_FILE", `Plan file not found: ${planFile}`);
229
+ // Validate plan content (not just existence)
230
+ const planContent = fs.readFileSync(planFile, "utf8");
231
+ const taskHeaders = planContent.match(/^## Task \d+/gm);
232
+ if (!taskHeaders || taskHeaders.length === 0)
233
+ return fail("INVALID_PLAN", "Plan file has no task headers (expected '## Task N')");
234
+ const doneWhenCount = (planContent.match(/\*\*Done when:\*\*/g) || []).length;
235
+ if (doneWhenCount < taskHeaders.length)
236
+ return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${doneWhenCount} 'Done when:' entries`);
210
237
  }
211
238
 
212
239
  if (target === "verified") {
@@ -228,14 +255,15 @@ function checkPreconditions(current, target, opts) {
228
255
  return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
229
256
  }
230
257
 
231
- // Gap-closure circuit breaker
258
+ // Gap-closure circuit breaker (configurable limit)
232
259
  if (target === "planned" && current.status === "verified") {
233
260
  const t = readTracking() || {};
234
261
  const cycles = (t.gap_cycles || {})[String(phase)] || 0;
235
- if (cycles >= 2) {
262
+ const limit = getGapCycleLimit();
263
+ if (cycles >= limit) {
236
264
  return fail(
237
265
  "GAP_CYCLE_LIMIT",
238
- `Phase ${phase} has failed verification ${cycles} times. Escalate to Fawzi or re-plan from scratch.`
266
+ `Phase ${phase} has failed verification ${cycles} times (limit: ${limit}). Escalate to Fawzi or re-plan from scratch.`
239
267
  );
240
268
  }
241
269
  }
@@ -294,6 +322,7 @@ function cmdCheck(opts) {
294
322
  assigned_to: s.assigned_to,
295
323
  verification: t.verification || "pending",
296
324
  gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
325
+ gap_cycle_limit: getGapCycleLimit(),
297
326
  tasks_done: t.tasks_done || 0,
298
327
  tasks_total: t.tasks_total || 0,
299
328
  deployed_url: t.deployed_url || "",
@@ -331,7 +360,6 @@ function cmdTransition(opts) {
331
360
 
332
361
  // Special: note/activity (no status change)
333
362
  if (target === "note" || target === "activity") {
334
- const now = new Date().toISOString().split("T")[0];
335
363
  if (opts.notes) t.notes = opts.notes;
336
364
  t.last_updated = new Date().toISOString();
337
365
  writeTracking(t);
@@ -353,7 +381,16 @@ function cmdTransition(opts) {
353
381
  target,
354
382
  { ...opts, phase }
355
383
  );
356
- if (!check.ok) return output(check);
384
+ if (!check.ok) {
385
+ // Force only bypasses status-ordering errors (PRECONDITION_FAILED, GAP_CYCLE_LIMIT).
386
+ // Never bypass MISSING_FILE, MISSING_ARG, INVALID_PLAN — those cause broken state.
387
+ const forceableErrors = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT"];
388
+ if (opts.force && forceableErrors.includes(check.error)) {
389
+ console.error(`WARNING: Forcing transition despite: ${check.message}`);
390
+ } else {
391
+ return output(check);
392
+ }
393
+ }
357
394
 
358
395
  const prevStatus = s.status;
359
396
 
@@ -597,6 +634,66 @@ function cmdFix(opts) {
597
634
  });
598
635
  }
599
636
 
637
+ function cmdValidatePlan(opts) {
638
+ const phase = parseInt(opts.phase) || 1;
639
+ const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
640
+
641
+ if (!fs.existsSync(planFile)) {
642
+ return output(fail("MISSING_FILE", `Plan file not found: ${planFile}`));
643
+ }
644
+
645
+ const content = fs.readFileSync(planFile, "utf8");
646
+ const errors = [];
647
+
648
+ // Check frontmatter exists
649
+ if (!/^---\n/.test(content)) {
650
+ errors.push("Missing frontmatter (---) at start of file");
651
+ }
652
+
653
+ // Check task count > 0
654
+ const taskHeaders = content.match(/^## Task \d+/gm);
655
+ if (!taskHeaders || taskHeaders.length === 0) {
656
+ errors.push("No task headers found (expected '## Task N — title')");
657
+ }
658
+
659
+ // Check "Done when" exists for each task
660
+ const taskCount = taskHeaders ? taskHeaders.length : 0;
661
+ const doneWhenCount = (content.match(/\*\*Done when:\*\*/g) || []).length;
662
+ if (doneWhenCount < taskCount) {
663
+ errors.push(
664
+ `${taskCount} tasks but only ${doneWhenCount} 'Done when:' entries`
665
+ );
666
+ }
667
+
668
+ // Check Success Criteria section exists
669
+ if (!/## Success Criteria/m.test(content)) {
670
+ errors.push("Missing '## Success Criteria' section");
671
+ }
672
+
673
+ // Check goal in frontmatter
674
+ if (!/^goal:/m.test(content)) {
675
+ errors.push("Missing 'goal:' in frontmatter");
676
+ }
677
+
678
+ if (errors.length > 0) {
679
+ return output({
680
+ ok: false,
681
+ error: "PLAN_VALIDATION_FAILED",
682
+ phase,
683
+ errors,
684
+ message: `Plan file has ${errors.length} issue(s)`,
685
+ });
686
+ }
687
+
688
+ output({
689
+ ok: true,
690
+ action: "validate-plan",
691
+ phase,
692
+ task_count: taskCount,
693
+ done_when_count: doneWhenCount,
694
+ });
695
+ }
696
+
600
697
  // ─── Output ──────────────────────────────────────────────
601
698
  function output(obj) {
602
699
  console.log(JSON.stringify(obj, null, 2));
@@ -620,11 +717,14 @@ switch (cmd) {
620
717
  case "fix":
621
718
  cmdFix(opts);
622
719
  break;
720
+ case "validate-plan":
721
+ cmdValidatePlan(opts);
722
+ break;
623
723
  default:
624
724
  output(
625
725
  fail(
626
726
  "UNKNOWN_COMMAND",
627
- `Usage: state.js <check|transition|init|fix> [--options]`
727
+ `Usage: state.js <check|transition|init|fix|validate-plan> [--options]`
628
728
  )
629
729
  );
630
730
  }
package/bin/statusline.js CHANGED
@@ -11,6 +11,7 @@ const fs = require("fs");
11
11
  const os = require("os");
12
12
  const path = require("path");
13
13
  const { spawnSync } = require("child_process");
14
+ const HOME = os.homedir();
14
15
 
15
16
  // ─── Colors (matches bin/qualia-ui.js palette) ───────────
16
17
  const TEAL = "\x1b[38;2;0;206;209m";
@@ -151,6 +152,47 @@ try {
151
152
  }
152
153
  } catch {}
153
154
 
155
+ // ─── Memory count ────────────────────────────────────────
156
+ let MEMORY_COUNT = 0;
157
+ try {
158
+ const dirKey = DIR.replace(/\//g, "-");
159
+ const memDir = path.join(HOME, ".claude", "projects", dirKey, "memory");
160
+ if (fs.existsSync(memDir)) {
161
+ const files = fs.readdirSync(memDir).filter(f => f.endsWith(".md") && f !== "MEMORY.md");
162
+ MEMORY_COUNT = files.length;
163
+ }
164
+ } catch {}
165
+
166
+ // ─── Hooks count ─────────────────────────────────────────
167
+ let HOOKS_COUNT = 0;
168
+ try {
169
+ const settingsPath = path.join(HOME, ".claude", "settings.json");
170
+ if (fs.existsSync(settingsPath)) {
171
+ const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
172
+ if (settings.hooks) {
173
+ for (const event of Object.values(settings.hooks)) {
174
+ if (Array.isArray(event)) {
175
+ for (const matcher of event) {
176
+ if (matcher.hooks && Array.isArray(matcher.hooks)) {
177
+ HOOKS_COUNT += matcher.hooks.length;
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ }
184
+ } catch {}
185
+
186
+ // ─── Skills count ────────────────────────────────────────
187
+ let SKILLS_COUNT = 0;
188
+ try {
189
+ const skillsDir = path.join(HOME, ".claude", "skills");
190
+ if (fs.existsSync(skillsDir)) {
191
+ const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
192
+ SKILLS_COUNT = entries.filter(e => e.isDirectory() || e.name.endsWith(".md")).length;
193
+ }
194
+ } catch {}
195
+
154
196
  // ─── Duration ────────────────────────────────────────────
155
197
  let DUR = "0s";
156
198
  try {
@@ -167,11 +209,11 @@ try {
167
209
  COST_FMT = `$${COST.toFixed(2)}`;
168
210
  } catch {}
169
211
 
170
- // ─── Line 1: Project + Git + Agent + Worktree + Phase ────
212
+ // ─── Line 1: Project + Git + Agent + Worktree + Phase + Memory + Hooks ──
171
213
  let LINE1 = "";
172
214
  try {
173
215
  const dirBase = path.basename(DIR) || DIR;
174
- LINE1 = `${TEAL}◆${RESET} ${WHITE}${dirBase}${RESET}`;
216
+ LINE1 = `${TEAL}⬢${RESET} ${WHITE}${dirBase}${RESET}`;
175
217
  if (BRANCH) {
176
218
  if (CHANGES > 0) {
177
219
  LINE1 += ` ${DIM}on${RESET} ${TEAL_GLOW}${BRANCH}${RESET} ${YELLOW}~${CHANGES}${RESET}`;
@@ -182,8 +224,16 @@ try {
182
224
  if (AGENT) LINE1 += ` ${DIM}│${RESET} ${TEAL}⚡${AGENT}${RESET}`;
183
225
  if (WORKTREE) LINE1 += ` ${DIM}│${RESET} ${TEAL_DIM}⎇ ${WORKTREE}${RESET}`;
184
226
  if (PHASE_INFO) LINE1 += ` ${DIM}│${RESET} ${PHASE_INFO}`;
227
+ // Memory, hooks, skills — context indicators
228
+ const contextParts = [];
229
+ if (MEMORY_COUNT > 0) contextParts.push(`${TEAL}⊙${RESET}${DIM}${MEMORY_COUNT}${RESET}`);
230
+ if (HOOKS_COUNT > 0) contextParts.push(`${TEAL_GLOW}⚙${RESET}${DIM}${HOOKS_COUNT}${RESET}`);
231
+ if (SKILLS_COUNT > 0) contextParts.push(`${TEAL_DIM}✦${RESET}${DIM}${SKILLS_COUNT}${RESET}`);
232
+ if (contextParts.length > 0) {
233
+ LINE1 += ` ${DIM}│${RESET} ${contextParts.join(` ${DIM}·${RESET} `)}`;
234
+ }
185
235
  } catch {
186
- LINE1 = `${TEAL}◆${RESET} ${WHITE}qualia${RESET}`;
236
+ LINE1 = `${TEAL}⬢${RESET} ${WHITE}qualia${RESET}`;
187
237
  }
188
238
 
189
239
  // ─── Line 2: Context bar + Cost + Duration + Model ───────
@@ -8,23 +8,43 @@ const path = require("path");
8
8
  const os = require("os");
9
9
  const { spawn, spawnSync } = require("child_process");
10
10
 
11
+ const _traceStart = Date.now();
12
+
11
13
  const CLAUDE_DIR = path.join(os.homedir(), ".claude");
12
14
  const CACHE_FILE = path.join(CLAUDE_DIR, ".qualia-last-update-check");
13
15
  const CONFIG_FILE = path.join(CLAUDE_DIR, ".qualia-config.json");
14
16
  const LOCK_FILE = path.join(CLAUDE_DIR, ".qualia-updating");
15
17
  const MAX_AGE_MS = 24 * 60 * 60 * 1000;
16
18
 
19
+ function _trace(hookName, result, extra) {
20
+ try {
21
+ const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
22
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
23
+ const entry = {
24
+ hook: hookName,
25
+ result,
26
+ timestamp: new Date().toISOString(),
27
+ duration_ms: Date.now() - _traceStart,
28
+ ...extra,
29
+ };
30
+ const traceFile = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
31
+ fs.appendFileSync(traceFile, JSON.stringify(entry) + "\n");
32
+ } catch {}
33
+ }
34
+
17
35
  try {
18
36
  // Fast path: recently checked
19
37
  if (fs.existsSync(CACHE_FILE)) {
20
38
  const last = Number(fs.readFileSync(CACHE_FILE, "utf8")) || 0;
21
39
  if (Date.now() - last * 1000 < MAX_AGE_MS) {
40
+ _trace("auto-update", "allow", { reason: "recently-checked" });
22
41
  process.exit(0);
23
42
  }
24
43
  }
25
44
 
26
45
  // Already updating
27
46
  if (fs.existsSync(LOCK_FILE)) {
47
+ _trace("auto-update", "allow", { reason: "already-updating" });
28
48
  process.exit(0);
29
49
  }
30
50
 
@@ -36,9 +56,13 @@ try {
36
56
  try {
37
57
  cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
38
58
  } catch {
59
+ _trace("auto-update", "allow", { reason: "config-unreadable" });
60
+ process.exit(0);
61
+ }
62
+ if (!cfg.code || !cfg.version) {
63
+ _trace("auto-update", "allow", { reason: "config-incomplete" });
39
64
  process.exit(0);
40
65
  }
41
- if (!cfg.code || !cfg.version) process.exit(0);
42
66
 
43
67
  // Fork the check-and-update into a detached background process so the hook
44
68
  // returns immediately and Claude Code is never blocked.
@@ -58,7 +82,7 @@ try {
58
82
  shell: process.platform === "win32",
59
83
  });
60
84
  const latest = ((r.stdout || "").trim());
61
- if (!latest) { fs.unlinkSync(LOCK_FILE); return; }
85
+ if (!latest) { fs.unlinkSync(LOCK_FILE); process.exit(0); }
62
86
  const cmp = (a, b) => {
63
87
  const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
64
88
  for (let i = 0; i < 3; i++) {
@@ -89,4 +113,5 @@ try {
89
113
  // Silent — never block the tool call
90
114
  }
91
115
 
116
+ _trace("auto-update", "allow", { reason: "check-spawned" });
92
117
  process.exit(0);
@@ -6,6 +6,8 @@
6
6
 
7
7
  const fs = require("fs");
8
8
 
9
+ const _traceStart = Date.now();
10
+
9
11
  function readInput() {
10
12
  try {
11
13
  const raw = fs.readFileSync(0, "utf8");
@@ -22,9 +24,29 @@ const file = (input.tool_input && (input.tool_input.file_path || input.tool_inpu
22
24
  // Normalize separators so Windows paths (C:\project\.env.local) also match.
23
25
  const normalized = String(file).replace(/\\/g, "/");
24
26
 
27
+ function _trace(hookName, result, extra) {
28
+ try {
29
+ const os = require("os");
30
+ const path = require("path");
31
+ const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
32
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
33
+ const entry = {
34
+ hook: hookName,
35
+ result,
36
+ timestamp: new Date().toISOString(),
37
+ duration_ms: Date.now() - _traceStart,
38
+ ...extra,
39
+ };
40
+ const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
41
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n");
42
+ } catch {}
43
+ }
44
+
25
45
  if (/\.env(\.|$)/.test(normalized)) {
26
46
  console.log("BLOCKED: Cannot edit environment files. Ask Fawzi to update secrets.");
47
+ _trace("block-env-edit", "block", { file: normalized });
27
48
  process.exit(2);
28
49
  }
29
50
 
51
+ _trace("block-env-edit", "allow");
30
52
  process.exit(0);