qualia-framework 7.0.1 → 7.2.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/qualia-ui.js CHANGED
@@ -20,6 +20,10 @@
20
20
  // plan-summary <path/to/plan.md> — story-file dashboard for a plan
21
21
  // journey-tree [path/to/JOURNEY.md] — ladder view of all milestones, current highlighted
22
22
  // milestone-complete <num> <name> <next> — celebration banner on milestone close
23
+ // spine [path/to/JOURNEY.md] — compact horizontal "you are here" journey map (start banner)
24
+ // onboard <name> — first-run milestone mental-model card (post-install, employees)
25
+ // phase-complete <num> <name> [k=v…] — per-phase celebration (tasks, mdone, mtotal, streak, next, nextname)
26
+ // clockout <name> [k=v…] — end-of-day report card (phases, tasks, commits, mdone, mtotal, streak, date, erp)
23
27
 
24
28
  const fs = require("fs");
25
29
  const path = require("path");
@@ -127,6 +131,17 @@ function progressBar(phase, total) {
127
131
  return `${bar} ${DIM}${pct}%${RESET}`;
128
132
  }
129
133
 
134
+ // Compact filled/empty tick bar (▰▱) for inline use in cards and the journey
135
+ // spine. Scales `done` of `total` onto `width` ticks. Returns "" when total<1.
136
+ function barTicks(done, total, width) {
137
+ const w = width || 5;
138
+ const t = Number(total) || 0;
139
+ if (t < 1) return "";
140
+ const d = Math.max(0, Math.min(t, Number(done) || 0));
141
+ const filled = Math.max(0, Math.min(w, Math.round((d / t) * w)));
142
+ return `${TEAL}${"▰".repeat(filled)}${DIM2}${"▱".repeat(w - filled)}${RESET}`;
143
+ }
144
+
130
145
  function colorForStatus(s) {
131
146
  const colors = {
132
147
  setup: DIM,
@@ -410,6 +425,166 @@ function cmdMilestoneComplete(num, name, nextName) {
410
425
  console.log("");
411
426
  }
412
427
 
428
+ // ─── Journey Spine (compact horizontal "you are here") ───
429
+ // One-line milestone ladder with the current milestone marked and a caret
430
+ // beneath it. Rendered by the session-start banner so an employee sees where
431
+ // they are in the arc the instant they open the terminal. Skips itself when
432
+ // there is no real multi-milestone journey to map (nothing to orient against).
433
+ function cmdSpine(journeyPath) {
434
+ const state = readState();
435
+ if (!state || !state.ok) return;
436
+ const current = state.milestone || 1;
437
+
438
+ // Milestone list: JOURNEY.md headers first, then state.milestones, else none.
439
+ let milestones = [];
440
+ const p = journeyPath || ".planning/JOURNEY.md";
441
+ try {
442
+ const content = fs.readFileSync(p, "utf8");
443
+ const re = /^## Milestone (\d+)\s*·\s*(.+?)\s*(?:\[[^\]]*\])?\r?$/gm;
444
+ let m;
445
+ while ((m = re.exec(content)) !== null) {
446
+ milestones.push({ num: parseInt(m[1]), name: m[2].trim() });
447
+ }
448
+ } catch {}
449
+ if (milestones.length === 0 && Array.isArray(state.milestones)) {
450
+ milestones = state.milestones
451
+ .map((x, i) => ({ num: x.num || i + 1, name: x.name || x.milestone_name || "" }));
452
+ }
453
+ // Nothing to orient against on a single-milestone (or unknown) project.
454
+ if (milestones.length < 2) return;
455
+ milestones.sort((a, b) => a.num - b.num);
456
+
457
+ // Build the colored spine while tracking the plain-text column of the current
458
+ // milestone's marker, so the caret lands exactly beneath it.
459
+ const SEP = "──";
460
+ const colored = [];
461
+ const plainSegs = [];
462
+ let caretCol = -1;
463
+ for (let i = 0; i < milestones.length; i++) {
464
+ const ms = milestones[i];
465
+ const isPast = ms.num < current;
466
+ const isCurrent = ms.num === current;
467
+ const marker = isPast ? `${GREEN}●${RESET}`
468
+ : isCurrent ? `${TEAL}${BOLD}◆${RESET}`
469
+ : `${DIM2}○${RESET}`;
470
+ const labelColor = isCurrent ? TEAL + BOLD : isPast ? DIM : WHITE;
471
+ const labelPlain = `M${ms.num} `;
472
+ if (isCurrent) {
473
+ const prefix = plainSegs.length ? plainSegs.join(SEP).length + SEP.length : 0;
474
+ caretCol = prefix + labelPlain.length; // column of the marker glyph
475
+ }
476
+ plainSegs.push(`${labelPlain}●`);
477
+ colored.push(`${labelColor}M${ms.num}${RESET} ${marker}`);
478
+ }
479
+
480
+ const LABEL_W = 10; // "Journey" label field width (2-space indent added below)
481
+ console.log("");
482
+ console.log(` ${pad(DIM + "Journey" + RESET, LABEL_W)}${colored.join(`${DIM2}${SEP}${RESET}`)}`);
483
+ if (caretCol >= 0) {
484
+ console.log(` ${" ".repeat(LABEL_W + caretCol)}${TEAL}↑${RESET} ${DIM}you are here${RESET}`);
485
+ }
486
+ const last = (state.last_activity || "").trim();
487
+ if (last && !/regenerated from increments/i.test(last)) {
488
+ const short = last.length > 56 ? last.slice(0, 55) + "…" : last;
489
+ console.log(` ${pad(DIM + "Last" + RESET, LABEL_W)}${DIM}${short}${RESET}`);
490
+ }
491
+ console.log("");
492
+ }
493
+
494
+ // ─── Onboard (first-run mental model for a new employee) ─
495
+ // Shown after install for non-OWNER roles: the milestone mental model + the one
496
+ // first move. Keeps a new hire from staring at a prompt wondering what to type.
497
+ function cmdOnboard(name) {
498
+ const who = name ? ` ${DIM}·${RESET} ${WHITE}welcome aboard, ${name}${RESET}` : ` ${DIM}·${RESET} ${WHITE}welcome aboard${RESET}`;
499
+ console.log("");
500
+ console.log(` ${TEAL}${BOLD}⬢${RESET} ${WHITE}${BOLD}QUALIA${RESET}${who}`);
501
+ console.log(` ${RULE_DIM}`);
502
+ console.log(` ${WHITE}You build in milestones:${RESET} ${TEAL}Plan ${DIM}→ ${TEAL}Build ${DIM}→ ${TEAL}Verify ${DIM}→ ${TEAL}Ship${RESET}`);
503
+ console.log(` ${DIM}You never have to guess what's next — the statusline shows where you are,${RESET}`);
504
+ console.log(` ${DIM}and ${RESET}${TEAL}/qualia${RESET}${DIM} always tells you the next command.${RESET}`);
505
+ console.log(` ${RULE_DIM}`);
506
+ console.log(` ${TEAL}⟶${RESET} ${WHITE}First move:${RESET} ${TEAL}${BOLD}/qualia${RESET}`);
507
+ console.log("");
508
+ }
509
+
510
+ // ─── Phase Complete (per-phase celebration + next move) ──
511
+ // Smaller sibling of milestone-complete, shown by /qualia-build (or /qualia-ship)
512
+ // when a phase closes. Optional metrics passed as key=value (tasks, mdone,
513
+ // mtotal, streak, next, nextname); any omitted metric is simply not rendered.
514
+ function cmdPhaseComplete(num, name, kv) {
515
+ kv = kv || {};
516
+ console.log("");
517
+ const title = name ? ` ${DIM}·${RESET} ${TEAL}${name}${RESET}` : "";
518
+ console.log(` ${GREEN}${BOLD}◆${RESET} ${WHITE}${BOLD}PHASE ${num} COMPLETE${RESET}${title}`);
519
+ console.log(` ${RULE_DIM}`);
520
+ const checks = [];
521
+ if (kv.tasks) checks.push(`${GREEN}✓${RESET} ${WHITE}${kv.tasks} tasks${RESET}`);
522
+ checks.push(`${GREEN}✓${RESET} ${WHITE}verified${RESET}`);
523
+ checks.push(`${GREEN}✓${RESET} ${WHITE}shipped${RESET}`);
524
+ console.log(` ${checks.join(" ")}`);
525
+ if (kv.mdone && kv.mtotal) {
526
+ const d = parseInt(kv.mdone, 10), t = parseInt(kv.mtotal, 10);
527
+ const remaining = t - d;
528
+ const tail = remaining <= 0 ? `${GREEN}milestone ready to close${RESET}`
529
+ : remaining === 1 ? `${DIM}one more to close the milestone${RESET}`
530
+ : `${DIM}${remaining} phases left in this milestone${RESET}`;
531
+ console.log(` ${pad(DIM + "Milestone" + RESET, 11)}${barTicks(d, t)} ${DIM}${d}/${t} phases · ${RESET}${tail}`);
532
+ }
533
+ if (kv.streak && parseInt(kv.streak, 10) > 1) {
534
+ console.log(` ${YELLOW}🔥 ${kv.streak} phases shipped today${RESET}`);
535
+ }
536
+ console.log(` ${RULE_DIM}`);
537
+ if (kv.next) {
538
+ const nn = kv.nextname ? ` ${DIM}(${kv.nextname})${RESET}` : "";
539
+ console.log(` ${TEAL}⟶${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${kv.next}${RESET}${nn}`);
540
+ }
541
+ console.log("");
542
+ }
543
+
544
+ // ─── Clock Out (end-of-day report card) ──────────────────
545
+ // Rendered by /qualia-report at shift close. Metrics passed as key=value
546
+ // (phases, tasks, commits, mdone, mtotal, streak, date, erp=ok|queued).
547
+ function cmdClockout(name, kv) {
548
+ kv = kv || {};
549
+ console.log("");
550
+ const who = name ? ` ${DIM}·${RESET} ${WHITE}${name}${RESET}` : "";
551
+ const date = kv.date ? ` ${DIM}·${RESET} ${DIM}${kv.date}${RESET}` : "";
552
+ console.log(` ${TEAL}${BOLD}▤${RESET} ${WHITE}${BOLD}CLOCK OUT${RESET}${who}${date}`);
553
+ console.log(` ${RULE_DIM}`);
554
+ const shipped = [];
555
+ if (kv.phases) shipped.push(`${WHITE}${kv.phases} phases${RESET}`);
556
+ if (kv.tasks) shipped.push(`${WHITE}${kv.tasks} tasks${RESET}`);
557
+ if (kv.commits) shipped.push(`${WHITE}${kv.commits} commits${RESET}`);
558
+ if (shipped.length) {
559
+ console.log(` ${pad(DIM + "Shipped" + RESET, 12)}${shipped.join(` ${DIM}·${RESET} `)}`);
560
+ }
561
+ if (kv.mdone && kv.mtotal) {
562
+ const d = parseInt(kv.mdone, 10), t = parseInt(kv.mtotal, 10);
563
+ const pct = t > 0 ? Math.round((d / t) * 100) : 0;
564
+ console.log(` ${pad(DIM + "Milestone" + RESET, 12)}${barTicks(d, t)} ${DIM}${pct}%${RESET}`);
565
+ }
566
+ if (kv.streak && parseInt(kv.streak, 10) >= 1) {
567
+ console.log(` ${pad(DIM + "Streak" + RESET, 12)}${YELLOW}${kv.streak} days${RESET}`);
568
+ }
569
+ console.log(` ${RULE_DIM}`);
570
+ const tail = kv.erp === "ok" ? `${GREEN}Report uploaded to ERP ✓${RESET}`
571
+ : kv.erp === "queued" ? `${YELLOW}Report queued — uploads next session${RESET}`
572
+ : `${DIM}Session report saved${RESET}`;
573
+ console.log(` ${tail} ${DIM}See you tomorrow.${RESET}`);
574
+ console.log("");
575
+ }
576
+
577
+ // Parse trailing `key=value` CLI args into an object (used by the lifecycle
578
+ // cards so optional metrics are positional-order-independent).
579
+ function parseKV(args) {
580
+ const o = {};
581
+ for (const a of args || []) {
582
+ const i = a.indexOf("=");
583
+ if (i > 0) o[a.slice(0, i)] = a.slice(i + 1);
584
+ }
585
+ return o;
586
+ }
587
+
413
588
  // ─── Plan Summary (story-file dashboard) ─────────────────
414
589
  // Renders a polished overview of a plan file: phase goal, tasks grouped by wave,
415
590
  // persona chips, dependency lines, AC count, validation count. Called by
@@ -733,6 +908,12 @@ module.exports = {
733
908
  divider,
734
909
  section,
735
910
  sectionClose,
911
+ // Lifecycle cards (called in-process by install.js / reused by skills)
912
+ onboard: cmdOnboard,
913
+ phaseComplete: cmdPhaseComplete,
914
+ clockout: cmdClockout,
915
+ spine: cmdSpine,
916
+ barTicks,
736
917
  // Existing helpers (kept exposed for reuse)
737
918
  pad,
738
919
  visibleLength,
@@ -776,9 +957,13 @@ switch (cmd) {
776
957
  case "plan-summary": cmdPlanSummary(rest[0]); break;
777
958
  case "journey-tree": cmdJourneyTree(rest[0]); break;
778
959
  case "milestone-complete": cmdMilestoneComplete(rest[0], rest[1], rest.slice(2).join(" ")); break;
960
+ case "spine": cmdSpine(rest[0]); break;
961
+ case "onboard": cmdOnboard(rest.join(" ")); break;
962
+ case "phase-complete": cmdPhaseComplete(rest[0], rest[1] || "", parseKV(rest.slice(2))); break;
963
+ case "clockout": cmdClockout(rest[0] || "", parseKV(rest.slice(1))); break;
779
964
  default:
780
965
  console.error(
781
- `Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end|update|plan-summary|journey-tree|milestone-complete> [args]`
966
+ `Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end|update|plan-summary|journey-tree|milestone-complete|spine|onboard|phase-complete|clockout> [args]`
782
967
  );
783
968
  process.exit(1);
784
969
  }
@@ -113,7 +113,10 @@ function buildPayload(options = {}) {
113
113
  ? { assignment_deadline: workPacket.deadline_date }
114
114
  : {}),
115
115
  client: tracking.client || "",
116
- client_report_id: env.CLIENT_REPORT_ID || "",
116
+ // ERP validates client_report_id as either a QS-REPORT-NN form or ABSENT.
117
+ // An empty string is rejected (422). Omit when unset so the server assigns
118
+ // a UUID, per docs/erp-contract.md (optional field).
119
+ ...(env.CLIENT_REPORT_ID ? { client_report_id: env.CLIENT_REPORT_ID } : {}),
117
120
  framework_version: config.version || "",
118
121
  milestone: tracking.milestone || 1,
119
122
  milestone_name: tracking.milestone_name || "",
@@ -27,6 +27,7 @@ const RUNTIME_BIN_SCRIPTS = [
27
27
  { file: "last-report.js", label: "last-report.js (router surfacing — newest session-report digest at session start)" },
28
28
  { file: "agent-runs.js", label: "agent-runs.js (agent telemetry writer)" },
29
29
  { file: "slop-detect.mjs", label: "slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)" },
30
+ { file: "dep-verify.mjs", label: "dep-verify.mjs (hallucinated/slopsquatted dependency scanner — verify + ship gate)" },
30
31
  { file: "erp-retry.js", label: "erp-retry.js (ERP report retry queue — drained by session-start hook and erp-flush CLI)" },
31
32
  { file: "erp-event.js", label: "erp-event.js (signed lifecycle-event emitter → ERP /api/v1/events, R14 client)" },
32
33
  { file: "work-packet.js", label: "work-packet.js (ERP mission/work packet pull + local reader)" },
package/bin/state.js CHANGED
@@ -655,7 +655,9 @@ function cmdTransitionIncrement(opts, target) {
655
655
  const t = ensureLifetime(readTracking() || {});
656
656
  inc.status = target;
657
657
  if (target === "planned") {
658
- if (prevStatus === "verified") inc.gap_cycles = (inc.gap_cycles || 0) + 1;
658
+ // A5 sets status "failed" on a failed verify (vs the legacy path which keeps
659
+ // "verified" + verification="fail"); count a gap cycle for either.
660
+ if (prevStatus === "verified" || prevStatus === "failed") inc.gap_cycles = (inc.gap_cycles || 0) + 1;
659
661
  } else if (target === "built") {
660
662
  inc.tasks_done = parseInt(opts.tasks_done) || 0;
661
663
  inc.tasks_total = parseInt(opts.tasks_total) || 0;
@@ -956,7 +958,7 @@ Resume: ${s.resume || "—"}
956
958
 
957
959
  // ─── Precondition Checks ─────────────────────────────────
958
960
  const VALID_FROM = {
959
- planned: ["setup", "verified"], // verified(fail) → planned = gap closure
961
+ planned: ["setup", "verified", "failed"], // verified(fail) [legacy] or failed [A5] → planned = gap closure
960
962
  built: ["planned"],
961
963
  verified: ["built"],
962
964
  polished: ["verified"],
@@ -1073,8 +1075,9 @@ function checkPreconditions(current, target, opts) {
1073
1075
  }
1074
1076
  }
1075
1077
 
1076
- // Gap-closure circuit breaker (configurable limit)
1077
- if (target === "planned" && current.status === "verified") {
1078
+ // Gap-closure circuit breaker (configurable limit). Fires from either the
1079
+ // legacy "verified"(+fail) status or the A5 "failed" status.
1080
+ if (target === "planned" && (current.status === "verified" || current.status === "failed")) {
1078
1081
  const t = readTracking() || {};
1079
1082
  const cycles = (t.gap_cycles || {})[String(phase)] || 0;
1080
1083
  const limit = getGapCycleLimit();
@@ -1148,6 +1151,10 @@ function nextCommand(status, phase, totalPhases, verification, lifecycle) {
1148
1151
  if (verification === "fail") return `/qualia-plan ${phase} --gaps`;
1149
1152
  if (phase < totalPhases) return `/qualia-plan ${phase + 1}`;
1150
1153
  return operate ? "/qualia-update" : "/qualia-polish";
1154
+ // A5 increments set status "failed" on a failed verify (legacy keeps
1155
+ // "verified"+fail). Route both to gap closure instead of dead-ending at /qualia.
1156
+ case "failed":
1157
+ return `/qualia-plan ${phase} --gaps`;
1151
1158
  case "polished":
1152
1159
  return "/qualia-ship";
1153
1160
  case "shipped":
package/bin/statusline.js CHANGED
@@ -205,7 +205,13 @@ try {
205
205
  parts.push(mStr);
206
206
  }
207
207
 
208
- if (total > 0) parts.push(`P${phase}/${total}`);
208
+ if (total > 0) {
209
+ // Always-visible forward-motion bar: phase of total, scaled to 5 ticks.
210
+ const w = 5;
211
+ const filled = Math.max(0, Math.min(w, Math.round((phase / total) * w)));
212
+ const ticks = "▰".repeat(filled) + "▱".repeat(w - filled);
213
+ parts.push(`P${phase}/${total} ${ticks}`);
214
+ }
209
215
  if (tasksTotal > 0) parts.push(`T${tasksDone}/${tasksTotal}`);
210
216
  if (status) parts.push(status);
211
217
 
@@ -27,7 +27,7 @@ const REQUIRED_HOOKS = [
27
27
  "session-start.js", "auto-update.js", "branch-guard.js", "pre-push.js",
28
28
  "pre-deploy-gate.js", "migration-guard.js", "git-guardrails.js",
29
29
  "stop-session-log.js", "fawzi-approval-guard.js", "vercel-account-guard.js", "env-empty-guard.js",
30
- "supabase-destructive-guard.js",
30
+ "supabase-destructive-guard.js", "secret-guard.js",
31
31
  ];
32
32
 
33
33
  const REQUIRED_DESIGN_FILES = [
@@ -346,20 +346,71 @@ if (fs.existsSync(slopScript)) {
346
346
  encoding: "utf8",
347
347
  timeout: 60000,
348
348
  });
349
- if (r.status === 1) {
350
- console.error("BLOCKED: anti-slop scan found CRITICAL design tells. Fix before deploying.");
349
+ // Fail CLOSED (same contract as the dep-verify gate below): only a clean
350
+ // exit 0 passes; a CRITICAL finding (1) or a crash/timeout blocks.
351
+ if (r.status !== 0) {
352
+ console.error(
353
+ r.status === 1
354
+ ? "BLOCKED: anti-slop scan found CRITICAL design tells. Fix before deploying."
355
+ : `BLOCKED: anti-slop scan did not complete (${r.error ? r.error.message : "exit " + r.status}). Treating as FAIL.`
356
+ );
351
357
  const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
352
358
  if (output) {
353
359
  const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
354
360
  for (const line of lines) console.error(` ${line}`);
355
361
  }
356
- _trace("pre-deploy-gate", "block", { gate: "slop", status: r.status });
362
+ _trace("pre-deploy-gate", "block", { gate: "slop", status: r.status, error: r.error ? r.error.message : undefined });
357
363
  process.exit(2);
358
364
  }
359
365
  console.log(" ✓ Anti-slop");
360
366
  }
361
367
  }
362
368
 
369
+ // Dependency verification: zero-token, zero-network scan for imports of
370
+ // packages that are BOTH undeclared in package.json AND absent from
371
+ // node_modules — the signature of an AI-hallucinated or slopsquatted
372
+ // dependency (the #1 named AI-generated-code security failure mode). The
373
+ // correctness/security companion to the design-focused anti-slop scan above.
374
+ // Skipped silently when the scanner isn't installed (brownfield / older
375
+ // installs). OWNER-only escape hatch mirrors QUALIA_SKIP_SLOP.
376
+ const depScript = path.join(QUALIA_HOME, "bin", "dep-verify.mjs");
377
+ if (fs.existsSync(depScript)) {
378
+ const skipDep = process.env.QUALIA_SKIP_DEPCHECK === "1";
379
+ if (skipDep) {
380
+ const depRole = String(readConfig().role || "").toUpperCase();
381
+ if (depRole !== "OWNER") {
382
+ const depState = readState();
383
+ blockDeploy("QUALIA_SKIP_DEPCHECK is OWNER-only.", (depState && depState.next_command) || "/qualia");
384
+ }
385
+ console.log(" ⚠ Dependency check skipped (QUALIA_SKIP_DEPCHECK=1)");
386
+ _trace("pre-deploy-gate", "skip-depcheck", { reason: "QUALIA_SKIP_DEPCHECK=1" });
387
+ } else {
388
+ const r = spawnSync(process.execPath, [depScript, "--severity=critical"], {
389
+ stdio: ["ignore", "pipe", "pipe"],
390
+ encoding: "utf8",
391
+ timeout: 60000,
392
+ });
393
+ // Fail CLOSED: only a clean exit 0 passes. Findings (1), invocation
394
+ // errors (2), or a crash/timeout (status null + r.error) all block — a
395
+ // security gate that silently passes when it cannot run is not a gate.
396
+ if (r.status !== 0) {
397
+ console.error(
398
+ r.status === 1
399
+ ? "BLOCKED: hallucinated/slopsquatted imports found. Fix before deploying."
400
+ : `BLOCKED: dep-verify did not complete (${r.error ? r.error.message : "exit " + r.status}). Treating as FAIL.`
401
+ );
402
+ const output = `${r.stdout || ""}${r.stderr || ""}`.trim();
403
+ if (output) {
404
+ const lines = output.split(/\r?\n/).filter(Boolean).slice(-20);
405
+ for (const line of lines) console.error(` ${line}`);
406
+ }
407
+ _trace("pre-deploy-gate", "block", { gate: "dep-verify", status: r.status, error: r.error ? r.error.message : undefined });
408
+ process.exit(2);
409
+ }
410
+ console.log(" ✓ Dependencies");
411
+ }
412
+ }
413
+
363
414
  console.log("⬢ All gates passed.");
364
415
 
365
416
  _trace("pre-deploy-gate", "allow");
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ // ~/.claude/hooks/secret-guard.js — block commits that stage secrets.
3
+ //
4
+ // Constitution non-negotiables: the service_role key is server-only and must
5
+ // never be committed; .env files must never be committed. Those were prose-only
6
+ // until now. This is the deterministic gate: a PreToolUse hook on
7
+ // `git commit*` that scans STAGED content (git diff --cached) for high-signal
8
+ // secret patterns and a staged `.env` file, and BLOCKS the commit (exit 2) if
9
+ // any are found.
10
+ //
11
+ // Design mirrors pre-deploy-gate.js: self-filter on the command, fail-CLOSED on
12
+ // a real match, OWNER-only escape (QUALIA_SECRET_SKIP=1), trace every decision.
13
+ // It NEVER prints the matched secret value — only the pattern name and file.
14
+ //
15
+ // Exits 2 to BLOCK. Exits 0 to allow. Cross-platform. No external deps.
16
+
17
+ const fs = require("fs");
18
+ const path = require("path");
19
+ const os = require("os");
20
+ const { spawnSync } = require("child_process");
21
+
22
+ const _traceStart = Date.now();
23
+
24
+ function qualiaHome() {
25
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
26
+ const parent = path.basename(path.dirname(__dirname));
27
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
28
+ return path.join(os.homedir(), ".claude");
29
+ }
30
+
31
+ const QUALIA_HOME = qualiaHome();
32
+ const CONFIG = path.join(QUALIA_HOME, ".qualia-config.json");
33
+ const SHELL = process.platform === "win32";
34
+ let HOOK_COMMAND = null;
35
+
36
+ // Self-filter: only act on `git commit`. Direct invocation (no stdin) runs the
37
+ // full scan so the test suite and manual runs work.
38
+ (function selfFilter() {
39
+ if (process.stdin.isTTY) return;
40
+ let command = null;
41
+ try {
42
+ const raw = fs.readFileSync(0, "utf8");
43
+ if (raw) {
44
+ const payload = JSON.parse(raw);
45
+ command = (payload && payload.tool_input && payload.tool_input.command) || "";
46
+ }
47
+ } catch {}
48
+ if (command === null) return; // no/!malformed stdin — run full scan
49
+ HOOK_COMMAND = command;
50
+ if (!/\bgit\s+commit\b/.test(command)) process.exit(0);
51
+ })();
52
+
53
+ function readConfig() {
54
+ try {
55
+ return JSON.parse(fs.readFileSync(CONFIG, "utf8"));
56
+ } catch {
57
+ return {};
58
+ }
59
+ }
60
+
61
+ function _trace(result, extra) {
62
+ try {
63
+ const traceDir = path.join(QUALIA_HOME, ".qualia-traces");
64
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
65
+ const entry = { hook: "secret-guard", result, timestamp: new Date().toISOString(), duration_ms: Date.now() - _traceStart, ...extra };
66
+ fs.appendFileSync(path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`), JSON.stringify(entry) + "\n");
67
+ } catch {}
68
+ }
69
+
70
+ // High-signal secret patterns. Narrow on purpose — false positives on a commit
71
+ // gate are expensive, so each pattern targets a real credential shape.
72
+ const SECRET_PATTERNS = [
73
+ { name: "private key block", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----/ },
74
+ { name: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/ },
75
+ { name: "OpenAI/Anthropic-style secret key", re: /\bsk-[A-Za-z0-9_-]{20,}\b/ },
76
+ { name: "GitHub token", re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
77
+ { name: "Supabase service_role key assignment", re: /SUPABASE_SERVICE_ROLE_KEY\s*[:=]\s*['"]?[A-Za-z0-9._-]{20,}/ },
78
+ { name: "service_role JWT", re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}[\s\S]{0,400}service_role/ },
79
+ ];
80
+
81
+ // A staged .env file (real env, not an example/template) is a leak by itself.
82
+ function stagedEnvFiles(names) {
83
+ return names.filter((f) => {
84
+ const base = path.basename(f);
85
+ if (!/^\.env(\.|$)/.test(base)) return false;
86
+ return !/\.(example|sample|template|dist)$/.test(base) && base !== ".env.example";
87
+ });
88
+ }
89
+
90
+ function git(args) {
91
+ return spawnSync("git", args, { encoding: "utf8", timeout: 8000, shell: SHELL });
92
+ }
93
+
94
+ function scan() {
95
+ // Staged file names.
96
+ const nameRes = git(["diff", "--cached", "--name-only"]);
97
+ if (nameRes.status !== 0) return { skipped: "not-a-repo-or-git-error" };
98
+ const names = (nameRes.stdout || "").split(/\r?\n/).filter(Boolean);
99
+ if (names.length === 0) return { clean: true, reason: "nothing-staged" };
100
+
101
+ const findings = [];
102
+
103
+ for (const env of stagedEnvFiles(names)) {
104
+ findings.push({ file: env, pattern: "staged .env file (never commit secrets)" });
105
+ }
106
+
107
+ // Added lines only (the `+` side of the staged diff).
108
+ const diffRes = git(["diff", "--cached", "--unified=0"]);
109
+ const diff = diffRes.stdout || "";
110
+ let currentFile = "?";
111
+ for (const line of diff.split(/\r?\n/)) {
112
+ if (line.startsWith("+++ b/")) { currentFile = line.slice(6); continue; }
113
+ if (!line.startsWith("+") || line.startsWith("+++")) continue;
114
+ for (const p of SECRET_PATTERNS) {
115
+ if (p.re.test(line)) findings.push({ file: currentFile, pattern: p.name });
116
+ }
117
+ }
118
+ return { findings };
119
+ }
120
+
121
+ function blockAllowedByOwner() {
122
+ if (process.env.QUALIA_SECRET_SKIP !== "1" && !/\bQUALIA_SECRET_SKIP=1\b/.test(HOOK_COMMAND || "")) return false;
123
+ const role = String(readConfig().role || "").toUpperCase();
124
+ if (role === "OWNER") return true;
125
+ console.error("BLOCKED: QUALIA_SECRET_SKIP is OWNER-only.");
126
+ _trace("block", { reason: "skip-non-owner" });
127
+ process.exit(2);
128
+ }
129
+
130
+ let result;
131
+ try {
132
+ result = scan();
133
+ } catch (e) {
134
+ // Fail OPEN on a scan error — a commit gate that errors must not brick every
135
+ // commit. A real match (below) still blocks.
136
+ console.error(`⚠ secret-guard: scan error (${e.message}). Commit proceeding.`);
137
+ _trace("warn", { error: e.message });
138
+ process.exit(0);
139
+ }
140
+
141
+ if (result && result.findings && result.findings.length > 0) {
142
+ if (blockAllowedByOwner()) {
143
+ console.error("⚠ secret-guard: findings present but QUALIA_SECRET_SKIP=1 (OWNER). Commit proceeding.");
144
+ _trace("allow", { skipped_owner: true, count: result.findings.length });
145
+ process.exit(0);
146
+ }
147
+ console.error("BLOCKED: secret(s) detected in staged content. Unstage and remove before committing.");
148
+ // Dedupe file+pattern; never print the matched value.
149
+ const seen = new Set();
150
+ for (const f of result.findings) {
151
+ const key = `${f.file}::${f.pattern}`;
152
+ if (seen.has(key)) continue;
153
+ seen.add(key);
154
+ console.error(` ✗ ${f.file} — ${f.pattern}`);
155
+ }
156
+ console.error(" Fix: move the secret to an env var / secrets manager; add the file to .gitignore.");
157
+ _trace("block", { count: seen.size });
158
+ process.exit(2);
159
+ }
160
+
161
+ _trace("allow", result || {});
162
+ process.exit(0);
@@ -225,6 +225,7 @@ try {
225
225
  fallbackText();
226
226
  } else if (fs.existsSync(STATE_FILE)) {
227
227
  runUi("banner", "router");
228
+ runUi("spine"); // horizontal "you are here" journey map (self-skips if <2 milestones)
228
229
  renderWorkPacketContext();
229
230
  const next = getNextCommand();
230
231
  if (next) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualia-framework",
3
- "version": "7.0.1",
3
+ "version": "7.2.0",
4
4
  "description": "Claude Code and Codex workflow framework by Qualia Solutions. Plan, build, verify, ship.",
5
5
  "bin": {
6
6
  "qualia-framework": "./bin/cli.js"
@@ -30,6 +30,9 @@
30
30
  "test:lib": "bash tests/lib.test.sh",
31
31
  "test:skills": "bash tests/skills.test.sh",
32
32
  "test:slop-detect": "bash tests/slop-detect.test.sh",
33
+ "test:dep-verify": "bash tests/dep-verify.test.sh",
34
+ "test:runtime-parity": "bash tests/runtime-parity.test.sh",
35
+ "test:secret-guard": "bash tests/secret-guard.test.sh",
33
36
  "test:statusline": "bash tests/statusline.test.sh",
34
37
  "test:refs": "bash tests/refs.test.sh",
35
38
  "test:published-install": "bash tests/published-install-smoke.test.sh",
@@ -172,11 +172,18 @@ Status protocol (machine-readable fan-in — do this, do not skip):
172
172
  (use BLOCKED or PARTIAL with `--note \"why\"` instead of DONE if you could not finish)
173
173
 
174
174
  Execute. Commit. Write your DONE/BLOCKED/PARTIAL status. Return DONE/BLOCKED/PARTIAL.
175
- ", subagent_type="qualia-builder", description="Task {N}: {title}")
175
+ ", subagent_type="qualia-builder", model="{tier}", description="Task {N}: {title}")
176
176
  ```
177
177
 
178
178
  **Cache ordering:** Role + grounding FIRST, phase_context SECOND, task_context LAST. Stable prefix ~2-5k tokens → 92% cache hit. Pre-inline eliminates 3-5 Read calls per builder.
179
179
 
180
+ **Model routing (intelligent model routing — the OpEx lever).** Most builders are *implementers*, not architects: they follow a precise task contract (files, AC, validation) within established patterns. That is exactly the deterministic, low-ambiguity work that belongs on a cheaper, faster model — reserving the frontier model for the judgment-heavy steps (planning, verification, skeptic adjudication). Per task, pass `model=`:
181
+ - **`sonnet`** (default for builders) — single-/few-file tasks against established patterns, CRUD, wiring, styling, test scaffolding. The bulk of every phase.
182
+ - **frontier (omit `model=` → inherits the session model)** — tasks the contract marks high-complexity, the `architect` persona, novel algorithms, or anything touching auth/payments/migrations where a wrong-but-plausible implementation is expensive.
183
+ - **`haiku`** — pure mechanical edits (rename sweeps, import fixes, format-only changes); pairs naturally with `/qualia-build --batch`.
184
+
185
+ When unsure, default to `sonnet`; escalate only when the task carries real ambiguity or correctness risk. This routes the high-volume mechanical work off the frontier model without weakening the steps where model strength actually changes the outcome.
186
+
180
187
  **After each task:**
181
188
  - Verify commit: `git log --oneline -1`
182
189
  - Show:
@@ -155,6 +155,17 @@ fi
155
155
  node ${QUALIA_BIN}/qualia-ui.js divider
156
156
  node ${QUALIA_BIN}/qualia-ui.js ok "Report $CLIENT_REPORT_ID complete."
157
157
  node ${QUALIA_BIN}/qualia-ui.js info "Shift report submitted. You can clock out now."
158
+
159
+ # End-of-day report card. Name from config; commits = $COUNT; milestone
160
+ # progress from state. erp=ok when the upload confirmed, erp=queued when it was
161
+ # enqueued for retry. Every metric self-omits if its variable is empty.
162
+ EMP_NAME=$(node -pe 'try{JSON.parse(require("fs").readFileSync(process.env.HOME+"/.claude/.qualia-config.json","utf8")).installed_by||""}catch{""}' 2>/dev/null)
163
+ MDONE=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase||""}catch{""}')
164
+ MTOTAL=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).total_phases||""}catch{""}')
165
+ node ${QUALIA_BIN}/qualia-ui.js clockout "$EMP_NAME" date="{YYYY-MM-DD}" \
166
+ ${COUNT:+commits=$COUNT} \
167
+ ${MDONE:+mdone=$MDONE} ${MTOTAL:+mtotal=$MTOTAL} \
168
+ ${ERP_RESULT:+erp=$ERP_RESULT}
158
169
  ```
159
170
 
160
171
  ## Common errors (read this when something goes wrong)
@@ -66,9 +66,10 @@ if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.
66
66
  if node -e "const p=require('./package.json');process.exit(p.scripts&&p.scripts.test?0:1)"; then npm test; fi
67
67
  npm run build # Build — must succeed
68
68
  node ${QUALIA_BIN}/slop-detect.mjs --severity=critical # Anti-slop — CRITICAL design tells block ship
69
+ node ${QUALIA_BIN}/dep-verify.mjs --severity=critical # Hallucinated/slopsquatted imports block ship
69
70
  ```
70
71
 
71
- The `pre-deploy-gate.js` hook re-runs the anti-slop scan at `vercel --prod` time as the hard, non-bypassable gate (OWNER-only `QUALIA_SKIP_SLOP=1` escape). This step surfaces failures early so they're fixed before the deploy command.
72
+ `dep-verify` blocks ship when any import references a package that is neither declared in `package.json` nor installed in `node_modules` — catching AI-invented or typosquatted dependencies before they reach production. The `pre-deploy-gate.js` hook re-runs **both** scans at `vercel --prod` time as the hard, non-bypassable gate — anti-slop (OWNER-only `QUALIA_SKIP_SLOP=1` escape) and dep-verify (OWNER-only `QUALIA_SKIP_DEPCHECK=1` escape, for the rare known-good import not yet in `package.json`). Both gates fail **closed**: a crash or timeout in either scanner blocks the deploy rather than passing silently. This step surfaces failures early so they're fixed before the deploy command.
72
73
 
73
74
  On failure:
74
75
  1. Summarize what failed in plain language
@@ -221,6 +222,21 @@ fi
221
222
  ```
222
223
  Do NOT manually edit STATE.md or tracking.json — state.js handles both.
223
224
 
225
+ ```bash
226
+ # Per-phase celebration card. Reads the freshly-updated state so the phase
227
+ # number, name, and milestone progress bar are accurate. Streak = phases this
228
+ # employee shipped today (from the trace log); omitted if unavailable. Every
229
+ # metric self-omits when missing, so this never fabricates.
230
+ PHASE_NUM=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase||""}catch{""}')
231
+ PHASE_NAME=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).phase_name||""}catch{""}')
232
+ TOTAL_PHASES=$(node ${QUALIA_BIN}/state.js check 2>/dev/null | node -pe 'try{JSON.parse(require("fs").readFileSync(0)).total_phases||""}catch{""}')
233
+ node ${QUALIA_BIN}/qualia-ui.js phase-complete "${PHASE_NUM:-?}" "$PHASE_NAME" \
234
+ ${TASKS_DONE:+tasks=$TASKS_DONE} \
235
+ ${PHASE_NUM:+mdone=$PHASE_NUM} ${TOTAL_PHASES:+mtotal=$TOTAL_PHASES} \
236
+ ${STREAK:+streak=$STREAK} \
237
+ next=/qualia-handoff
238
+ ```
239
+
224
240
  ```bash
225
241
  node ${QUALIA_BIN}/qualia-ui.js end "SHIPPED" "/qualia-handoff"
226
242
  ```