qualia-framework 4.0.0 → 4.0.3

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.
Files changed (45) hide show
  1. package/CLAUDE.md +23 -11
  2. package/agents/plan-checker.md +1 -1
  3. package/bin/cli.js +18 -13
  4. package/bin/install.js +34 -45
  5. package/bin/qualia-ui.js +2 -2
  6. package/bin/state.js +74 -7
  7. package/bin/statusline.js +4 -1
  8. package/docs/erp-contract.md +12 -0
  9. package/guide.md +1 -1
  10. package/hooks/migration-guard.js +23 -9
  11. package/hooks/pre-compact.js +39 -11
  12. package/hooks/pre-deploy-gate.js +3 -4
  13. package/hooks/pre-push.js +6 -3
  14. package/hooks/session-start.js +8 -8
  15. package/package.json +1 -1
  16. package/rules/frontend.md +5 -13
  17. package/skills/qualia/SKILL.md +5 -0
  18. package/skills/qualia-build/SKILL.md +10 -0
  19. package/skills/qualia-debug/SKILL.md +6 -0
  20. package/skills/qualia-design/SKILL.md +9 -1
  21. package/skills/qualia-discuss/SKILL.md +6 -0
  22. package/skills/qualia-handoff/SKILL.md +5 -0
  23. package/skills/qualia-help/SKILL.md +18 -4
  24. package/skills/qualia-idk/SKILL.md +6 -0
  25. package/skills/qualia-learn/SKILL.md +6 -0
  26. package/skills/qualia-map/SKILL.md +7 -0
  27. package/skills/qualia-milestone/SKILL.md +6 -0
  28. package/skills/qualia-new/SKILL.md +13 -1
  29. package/skills/qualia-optimize/SKILL.md +8 -0
  30. package/skills/qualia-pause/SKILL.md +5 -0
  31. package/skills/qualia-plan/SKILL.md +11 -1
  32. package/skills/qualia-polish/SKILL.md +8 -0
  33. package/skills/qualia-quick/SKILL.md +7 -0
  34. package/skills/qualia-report/SKILL.md +5 -0
  35. package/skills/qualia-research/SKILL.md +7 -0
  36. package/skills/qualia-resume/SKILL.md +3 -0
  37. package/skills/qualia-review/SKILL.md +7 -0
  38. package/skills/qualia-ship/SKILL.md +5 -0
  39. package/skills/qualia-skill-new/SKILL.md +6 -0
  40. package/skills/qualia-task/SKILL.md +8 -1
  41. package/skills/qualia-test/SKILL.md +7 -0
  42. package/skills/qualia-verify/SKILL.md +8 -0
  43. package/templates/help.html +4 -4
  44. package/tests/hooks.test.sh +5 -5
  45. package/tests/runner.js +212 -3
package/CLAUDE.md CHANGED
@@ -23,25 +23,37 @@ Next.js 16+, React 19, TypeScript, Supabase, Vercel. Voice: Retell AI, ElevenLab
23
23
 
24
24
  ## The Road (how projects flow)
25
25
 
26
+ v4 hierarchy: **Project → Journey → Milestones (2–5, Handoff always last) → Phases (2–5 tasks each) → Tasks (one commit, one verification contract).**
27
+
26
28
  ```
27
- /qualia-new set up project
29
+ /qualia-new kickoff + parallel research + JOURNEY.md (all milestones upfront)
30
+ add --auto to chain the whole road end-to-end
28
31
 
29
- For each phase:
30
- /qualia-plan → plan the phase (planner agent, fresh context)
31
- /qualia-build → build it (builder subagents per task, fresh context each)
32
- /qualia-verify verify it works (verifier agent, goal-backward checks)
32
+ For each milestone, for each phase:
33
+ /qualia-plan → plan the phase (planner + plan-checker revision loop, fresh context)
34
+ /qualia-build → build it (builder subagents per task, wave-based parallel)
35
+ /qualia-verify goal-backward check (verifier agent, fresh context)
33
36
 
34
- /qualia-polish design/UX pass
35
- /qualia-ship → deploy to production
36
- /qualia-handoff deliver to client
37
+ /qualia-milestone close milestone, archive artifacts, prep next (human gate)
38
+ (repeat for each milestone until Handoff)
39
+ Final milestone = Handoff:
40
+ /qualia-polish → design/UX pass (Phase 1 of Handoff)
41
+ (content + SEO) → Phase 2
42
+ (final QA) → Phase 3
43
+ /qualia-ship → deploy to production (quality gates → deploy → verify)
44
+ /qualia-handoff → 4 deliverables: credentials, doc, final update, report
37
45
 
38
46
  Done.
39
47
 
40
- Lost? → /qualia (tells you exactly what's next)
41
- Quick fix? → /qualia-quick (skip planning for small tasks)
42
- End of day? → /qualia-report (mandatory before clock-out)
48
+ Lost? → /qualia (state router — tells you the next command)
49
+ Stuck/weird? → /qualia-idk (diagnostic spawns plan-view + code-view agents in parallel)
50
+ Quick fix? → /qualia-quick (skip planning for small tasks)
51
+ Paused? → /qualia-resume (restore from .continue-here.md or STATE.md)
52
+ End of day? → /qualia-report (mandatory before clock-out; writes ERP payload)
43
53
  ```
44
54
 
55
+ **Human gates:** journey approval after `/qualia-new`, then one at each milestone boundary via `/qualia-milestone`. `--auto` runs everything between gates automatically.
56
+
45
57
  ## Context Isolation
46
58
  Every task runs in a fresh subagent context. Task 50 gets the same quality as Task 1.
47
59
  - Planner gets: PROJECT.md + phase requirements
@@ -34,7 +34,7 @@ Plan must have YAML frontmatter with:
34
34
 
35
35
  **FAIL if:** frontmatter missing, incomplete, or `goal` differs from ROADMAP.md.
36
36
 
37
- ### Rule 2: Every task has the 6 mandatory story-file fields
37
+ ### Rule 2: Every task has the 7 mandatory story-file fields
38
38
 
39
39
  Each `## Task N — title` block must include ALL of these:
40
40
 
package/bin/cli.js CHANGED
@@ -126,18 +126,23 @@ function cmdUpdate() {
126
126
  // non-Qualia entries in settings.json (other hooks, user env vars, etc.).
127
127
  // --yes / -y skips the confirmation prompt for scripted use.
128
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.
129
+ // Current Qualia hook filenames — only these are removed from ~/.claude/hooks/,
130
+ // any other hooks the user dropped in there are left alone. The LEGACY set
131
+ // lists hooks that were shipped by older framework versions but have since
132
+ // been removed; uninstall still tries to clean them so old installs get a
133
+ // clean removal.
131
134
  const QUALIA_HOOK_FILES = [
132
135
  "session-start.js",
133
136
  "auto-update.js",
134
137
  "branch-guard.js",
135
138
  "pre-push.js",
136
- "block-env-edit.js",
137
139
  "migration-guard.js",
138
140
  "pre-deploy-gate.js",
139
141
  "pre-compact.js",
140
142
  ];
143
+ const QUALIA_LEGACY_HOOK_FILES = [
144
+ "block-env-edit.js", // removed in v3.2.0
145
+ ];
141
146
 
142
147
  // 4 Qualia agents — only these are removed.
143
148
  const QUALIA_AGENT_FILES = ["planner.md", "builder.md", "verifier.md", "qa-browser.md"];
@@ -210,14 +215,14 @@ function cleanSettingsJson(counters) {
210
215
  };
211
216
 
212
217
  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
- }
218
+ // Iterate every hook event key, not a hardcoded subset — future hook
219
+ // events added by Claude Code or the framework get cleaned automatically.
220
+ for (const key of Object.keys(settings.hooks)) {
221
+ const cleaned = filterHookArray(settings.hooks[key]);
222
+ if (cleaned && cleaned.length > 0) {
223
+ settings.hooks[key] = cleaned;
224
+ } else {
225
+ delete settings.hooks[key];
221
226
  }
222
227
  }
223
228
  // If hooks is now empty, remove it entirely.
@@ -305,8 +310,8 @@ async function cmdUninstall() {
305
310
  safeUnlink(path.join(CLAUDE_DIR, "agents", f), counters);
306
311
  }
307
312
 
308
- // Hooks — only the 8 Qualia ones.
309
- for (const f of QUALIA_HOOK_FILES) {
313
+ // Hooks — current set plus any legacy hook filenames from older versions.
314
+ for (const f of [...QUALIA_HOOK_FILES, ...QUALIA_LEGACY_HOOK_FILES]) {
310
315
  safeUnlink(path.join(CLAUDE_DIR, "hooks", f), counters);
311
316
  }
312
317
 
package/bin/install.js CHANGED
@@ -599,16 +599,23 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
599
599
  // bash/Git Bash requirement on Windows.
600
600
  const hd = path.join(CLAUDE_DIR, "hooks");
601
601
  const nodeCmd = (hookFile) => `node "${path.join(hd, hookFile)}"`;
602
- settings.hooks = {
602
+ const QUALIA_HOOK_SET = new Set([
603
+ "session-start.js", "auto-update.js", "branch-guard.js", "pre-push.js",
604
+ "pre-deploy-gate.js", "migration-guard.js", "pre-compact.js",
605
+ ]);
606
+ const isQualiaHookCmd = (cmd) => {
607
+ if (typeof cmd !== "string") return false;
608
+ for (const h of QUALIA_HOOK_SET) if (cmd.includes(h)) return true;
609
+ return false;
610
+ };
611
+
612
+ // Our canonical hook definitions, grouped per event/matcher.
613
+ const qualiaHooks = {
603
614
  SessionStart: [
604
615
  {
605
616
  matcher: ".*",
606
617
  hooks: [
607
- {
608
- type: "command",
609
- command: nodeCmd("session-start.js"),
610
- timeout: 5,
611
- },
618
+ { type: "command", command: nodeCmd("session-start.js"), timeout: 5 },
612
619
  ],
613
620
  },
614
621
  ],
@@ -616,44 +623,16 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
616
623
  {
617
624
  matcher: "Bash",
618
625
  hooks: [
619
- {
620
- type: "command",
621
- command: nodeCmd("auto-update.js"),
622
- timeout: 5,
623
- },
624
- {
625
- type: "command",
626
- if: "Bash(git push*)",
627
- command: nodeCmd("branch-guard.js"),
628
- timeout: 5,
629
- statusMessage: "⬢ Checking branch permissions...",
630
- },
631
- {
632
- type: "command",
633
- if: "Bash(git push*)",
634
- command: nodeCmd("pre-push.js"),
635
- timeout: 15,
636
- statusMessage: "⬢ Syncing tracking...",
637
- },
638
- {
639
- type: "command",
640
- if: "Bash(vercel --prod*)",
641
- command: nodeCmd("pre-deploy-gate.js"),
642
- timeout: 180,
643
- statusMessage: "⬢ Running quality gates...",
644
- },
626
+ { type: "command", command: nodeCmd("auto-update.js"), timeout: 5 },
627
+ { type: "command", if: "Bash(git push*)", command: nodeCmd("branch-guard.js"), timeout: 5, statusMessage: "⬢ Checking branch permissions..." },
628
+ { type: "command", if: "Bash(git push*)", command: nodeCmd("pre-push.js"), timeout: 15, statusMessage: "⬢ Syncing tracking..." },
629
+ { type: "command", if: "Bash(vercel --prod*)", command: nodeCmd("pre-deploy-gate.js"), timeout: 180, statusMessage: "⬢ Running quality gates..." },
645
630
  ],
646
631
  },
647
632
  {
648
633
  matcher: "Edit|Write",
649
634
  hooks: [
650
- {
651
- type: "command",
652
- if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)",
653
- command: nodeCmd("migration-guard.js"),
654
- timeout: 10,
655
- statusMessage: "⬢ Checking migration safety...",
656
- },
635
+ { type: "command", if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)", command: nodeCmd("migration-guard.js"), timeout: 10, statusMessage: "⬢ Checking migration safety..." },
657
636
  ],
658
637
  },
659
638
  ],
@@ -661,17 +640,27 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
661
640
  {
662
641
  matcher: "compact",
663
642
  hooks: [
664
- {
665
- type: "command",
666
- command: nodeCmd("pre-compact.js"),
667
- timeout: 15,
668
- statusMessage: "⬢ Saving state...",
669
- },
643
+ { type: "command", command: nodeCmd("pre-compact.js"), timeout: 15, statusMessage: "⬢ Saving state..." },
670
644
  ],
671
645
  },
672
646
  ],
673
647
  };
674
648
 
649
+ // Merge user hooks: strip Qualia-owned commands, preserve everything else.
650
+ if (!settings.hooks || typeof settings.hooks !== "object") settings.hooks = {};
651
+ for (const event of Object.keys(qualiaHooks)) {
652
+ const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event] : [];
653
+ // Remove Qualia-owned command entries from each matcher block, drop empty blocks.
654
+ const cleaned = [];
655
+ for (const block of existing) {
656
+ if (!block || !Array.isArray(block.hooks)) continue;
657
+ const kept = block.hooks.filter((h) => !isQualiaHookCmd(h && h.command));
658
+ if (kept.length > 0) cleaned.push({ ...block, hooks: kept });
659
+ }
660
+ // Append our canonical blocks after the preserved user ones.
661
+ settings.hooks[event] = [...cleaned, ...qualiaHooks[event]];
662
+ }
663
+
675
664
  // Permissions — no restrictions on env files or branches.
676
665
  // Everyone can read/write .env, push to main.
677
666
  if (!settings.permissions) settings.permissions = {};
package/bin/qualia-ui.js CHANGED
@@ -320,10 +320,10 @@ function cmdJourneyTree(journeyPath) {
320
320
 
321
321
  // Project name from frontmatter if present
322
322
  const projMatch = content.match(/^project:\s*"?(.+?)"?\s*$/m);
323
- const projectName = projMatch ? projMatch[1] : projectName();
323
+ const projName = projMatch ? projMatch[1] : projectName();
324
324
 
325
325
  console.log("");
326
- console.log(` ${TEAL}${BOLD}◯${RESET} ${WHITE}${BOLD}JOURNEY${RESET} ${DIM}▸${RESET} ${WHITE}${projectName}${RESET}`);
326
+ console.log(` ${TEAL}${BOLD}◯${RESET} ${WHITE}${BOLD}JOURNEY${RESET} ${DIM}▸${RESET} ${WHITE}${projName}${RESET}`);
327
327
  console.log(` ${RULE_DIM}`);
328
328
  console.log(` ${DIM}${milestones.length} milestones · currently at M${currentMilestone}${RESET}`);
329
329
  console.log("");
package/bin/state.js CHANGED
@@ -9,6 +9,7 @@ const PLANNING = ".planning";
9
9
  const STATE_FILE = path.join(PLANNING, "STATE.md");
10
10
  const TRACKING_FILE = path.join(PLANNING, "tracking.json");
11
11
  const LOCK_FILE = path.join(PLANNING, ".state.lock");
12
+ const JOURNAL_FILE = path.join(PLANNING, ".state.journal");
12
13
 
13
14
  // ─── Atomic write (tmp + rename) ─────────────────────────
14
15
  // Prevents half-written files when SIGINT, OOM, or AV scanners
@@ -26,10 +27,60 @@ function atomicWrite(file, content) {
26
27
  }
27
28
  }
28
29
 
30
+ // ─── Write-ahead journal (two-file crash recovery) ──────
31
+ // STATE.md + tracking.json are written back-to-back. A SIGKILL or power
32
+ // loss between the two renames can leave the pair inconsistent. The
33
+ // journal captures the pre-write snapshot; if a journal is still present
34
+ // on next startup, we know the previous mutator crashed mid-write and
35
+ // restore both files to the pre-transaction state.
36
+ function writeJournal(preState, preTracking) {
37
+ if (!fs.existsSync(PLANNING)) return;
38
+ const payload = JSON.stringify({
39
+ ts: new Date().toISOString(),
40
+ pid: process.pid,
41
+ state: preState != null ? preState : null,
42
+ tracking: preTracking != null ? preTracking : null,
43
+ });
44
+ atomicWrite(JOURNAL_FILE, payload);
45
+ }
46
+ function clearJournal() {
47
+ try { fs.unlinkSync(JOURNAL_FILE); } catch {}
48
+ }
49
+ function recoverFromJournal() {
50
+ if (!fs.existsSync(JOURNAL_FILE)) return false;
51
+ try {
52
+ const raw = fs.readFileSync(JOURNAL_FILE, "utf8");
53
+ const j = JSON.parse(raw);
54
+ if (j.state != null) atomicWrite(STATE_FILE, j.state);
55
+ if (j.tracking != null) atomicWrite(TRACKING_FILE, j.tracking);
56
+ clearJournal();
57
+ try { _trace("state-journal", "recover", { from_pid: j.pid, journal_ts: j.ts }); } catch {}
58
+ return true;
59
+ } catch {
60
+ // Corrupt journal — best effort: remove so we don't loop on recovery.
61
+ clearJournal();
62
+ return false;
63
+ }
64
+ }
65
+
29
66
  // ─── Exclusive lock ──────────────────────────────────────
30
67
  // Prevents two concurrent state.js mutations from racing on the dual
31
68
  // STATE.md + tracking.json write. Read commands (check, validate-plan)
32
69
  // don't take the lock — only mutators do.
70
+
71
+ // Synchronous sleep without CPU spin. Atomics.wait on a zero-initialized
72
+ // SharedArrayBuffer blocks until the timeout elapses and yields the CPU.
73
+ function sleepSync(ms) {
74
+ try {
75
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
76
+ } catch {
77
+ // SharedArrayBuffer unavailable (extremely old runtimes) — last-resort
78
+ // tight loop, bounded to the requested duration.
79
+ const t = Date.now() + ms;
80
+ while (Date.now() < t) {}
81
+ }
82
+ }
83
+
33
84
  function acquireLock(timeoutMs = 5000) {
34
85
  if (!fs.existsSync(PLANNING)) return null; // nothing to lock yet
35
86
  const start = Date.now();
@@ -50,12 +101,13 @@ function acquireLock(timeoutMs = 5000) {
50
101
  continue;
51
102
  }
52
103
  } catch {}
53
- // Spin-wait briefly. State ops are fast; conflicts rare.
54
- const t = Date.now() + 50;
55
- while (Date.now() < t) {}
104
+ sleepSync(50);
56
105
  }
57
106
  }
58
- // Couldn't acquire — proceed unlocked rather than block the user.
107
+ // Couldn't acquire inside the budget — proceed unlocked rather than
108
+ // hard-block the user. Surface this in analytics so repeated contention
109
+ // is visible instead of silent.
110
+ try { _trace("state-lock", "fallthrough", { waited_ms: Date.now() - start }); } catch {}
59
111
  return null;
60
112
  }
61
113
 
@@ -613,14 +665,25 @@ function cmdTransition(opts) {
613
665
  t.deploy_count = (parseInt(t.deploy_count) || 0) + 1;
614
666
  }
615
667
 
616
- // Write both files
668
+ // Write both files. We write a journal snapshot of the pre-transition
669
+ // STATE.md + tracking.json first; if the process dies between the two
670
+ // real writes, the next invocation will see the journal and restore both
671
+ // files to the pre-transition state. Each individual write is torn-write
672
+ // safe (tmp + rename); the journal closes the gap between the two.
617
673
  const backupState = readState();
674
+ const backupTracking = (() => {
675
+ try { return fs.readFileSync(TRACKING_FILE, "utf8"); } catch { return null; }
676
+ })();
618
677
  try {
678
+ writeJournal(backupState, backupTracking);
619
679
  writeStateMd(s);
620
680
  writeTracking(t);
681
+ clearJournal();
621
682
  } catch (e) {
622
- // Revert STATE.md on failure (atomic so the revert itself is safe)
623
- if (backupState) atomicWrite(STATE_FILE, backupState);
683
+ // Revert whichever file is out of sync with pre-transition state.
684
+ try { if (backupState) atomicWrite(STATE_FILE, backupState); } catch {}
685
+ try { if (backupTracking) atomicWrite(TRACKING_FILE, backupTracking); } catch {}
686
+ clearJournal();
624
687
  return output(fail("WRITE_ERROR", e.message));
625
688
  }
626
689
 
@@ -1193,6 +1256,10 @@ const opts = parseArgs(rest);
1193
1256
  const READ_ONLY = new Set(["check", "validate-plan"]);
1194
1257
  let __lock = null;
1195
1258
  if (!READ_ONLY.has(cmd)) {
1259
+ // Before acquiring the lock, recover from any journal left by a crashed
1260
+ // previous mutator. Runs for mutators only; read commands should still
1261
+ // return the actual on-disk state even if it's mid-recovery.
1262
+ try { recoverFromJournal(); } catch {}
1196
1263
  __lock = acquireLock();
1197
1264
  process.on("exit", () => releaseLock(__lock));
1198
1265
  process.on("SIGINT", () => { releaseLock(__lock); process.exit(130); });
package/bin/statusline.js CHANGED
@@ -175,7 +175,10 @@ try {
175
175
  // ─── Memory count ────────────────────────────────────────
176
176
  let MEMORY_COUNT = 0;
177
177
  try {
178
- const dirKey = DIR.replace(/\//g, "-");
178
+ // Claude Code uses a hyphenated encoding of the project directory. Replace
179
+ // BOTH forward and backward slashes so Windows installs (where DIR contains
180
+ // `\`) get a correct key and the memory count renders.
181
+ const dirKey = DIR.replace(/[\/\\]/g, "-");
179
182
  const memDir = path.join(HOME, ".claude", "projects", dirKey, "memory");
180
183
  if (fs.existsSync(memDir)) {
181
184
  const files = fs.readdirSync(memDir).filter(f => f.endsWith(".md") && f !== "MEMORY.md");
@@ -39,6 +39,16 @@ Content-Type: application/json
39
39
  "git_remote": "github.com/QualiasolutionsCY/acme-portal",
40
40
  "client": "Client Name",
41
41
  "milestone": 2,
42
+ "milestone_name": "Core Product",
43
+ "milestones": [
44
+ {
45
+ "num": 1,
46
+ "name": "Foundation",
47
+ "closed_at": "2026-04-10T18:00:00Z",
48
+ "phases_completed": 3,
49
+ "tasks_completed": 12
50
+ }
51
+ ],
42
52
  "phase": 2,
43
53
  "phase_name": "Authentication & Dashboard",
44
54
  "total_phases": 4,
@@ -175,6 +185,8 @@ Authorization: Bearer <api-key>
175
185
  | submitted_by | string | yes | Team member name |
176
186
  | submitted_at | string | yes | ISO 8601 timestamp |
177
187
  | milestone | number | recommended | Current milestone number (1-indexed) |
188
+ | milestone_name | string | recommended (v4+) | Human name of the current milestone — from JOURNEY.md / tracking.json |
189
+ | milestones | array | recommended (v4+) | Array of closed milestone summaries: `{num, name, closed_at, phases_completed, tasks_completed}`. Renders the journey tree on the ERP. |
178
190
  | lifetime | object | recommended | Cumulative counters — tasks_completed, phases_completed, milestones_completed, total_phases, last_closed_milestone |
179
191
  | project_id | string | recommended (v3.6+) | Stable per-project identifier — preferred dedupe key over `project` slug. Survives directory renames. |
180
192
  | team_id | string | recommended (v3.6+) | Installation's team identifier. Composite `(team_id, project_id)` is the canonical project key. |
package/guide.md CHANGED
@@ -43,7 +43,7 @@ Append `--auto` to `/qualia-new` and the framework chains every step:
43
43
 
44
44
  **Plus one halt case:** if a phase fails verification beyond the gap-cycle limit (default 2), the chain stops and asks for human intervention.
45
45
 
46
- ## The 10 Commands
46
+ ## The Road Commands
47
47
 
48
48
  | When | Command | What it does |
49
49
  |------|---------|-------------|
@@ -11,6 +11,10 @@ const _traceStart = Date.now();
11
11
  // Read JSON tool input from stdin with a safety timeout.
12
12
  // On Windows, fs.readFileSync(0) can hang if stdin isn't closed by the host.
13
13
  // We loop fs.readSync with a 1s deadline; if no data arrives, treat as empty.
14
+ // Between EAGAIN retries we sleep via Atomics.wait to avoid CPU-burning spin.
15
+ function sleepSync(ms) {
16
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch {}
17
+ }
14
18
  function readInput() {
15
19
  const deadline = Date.now() + 1000;
16
20
  const buf = Buffer.alloc(65536);
@@ -21,8 +25,8 @@ function readInput() {
21
25
  try {
22
26
  n = fs.readSync(0, buf, 0, buf.length, null);
23
27
  } catch (e) {
24
- // EAGAIN/EWOULDBLOCK: no data yet, retry until deadline
25
- if (e && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) continue;
28
+ // EAGAIN/EWOULDBLOCK: no data yet. Sleep 1ms and retry until deadline.
29
+ if (e && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) { sleepSync(1); continue; }
26
30
  // Any other read error: bail
27
31
  break;
28
32
  }
@@ -108,14 +112,24 @@ if (/ALTER\s+TABLE\s+[^;]*\bDROP\s+COLUMN\b/i.test(scan)) {
108
112
  errors.push("ALTER TABLE ... DROP COLUMN is destructive");
109
113
  }
110
114
 
111
- // DELETE without WHERE
112
- if (/DELETE\s+FROM/i.test(scan) && !/WHERE/i.test(scan)) {
113
- errors.push("DELETE FROM without WHERE clause");
115
+ // DELETE / UPDATE without WHERE — check per-statement, not file-global.
116
+ // Previously a file containing "DELETE FROM foo;" followed by any later
117
+ // "... WHERE ..." (in a SELECT, JOIN, etc.) would pass the check.
118
+ function splitStatements(src) {
119
+ return src.split(/;/g).map((s) => s.trim()).filter(Boolean);
114
120
  }
115
-
116
- // UPDATE without WHERE affects every row
117
- if (/\bUPDATE\s+\w+(?:\.\w+)?\s+SET\b/i.test(scan) && !/WHERE/i.test(scan)) {
118
- errors.push("UPDATE without WHERE clause — affects every row");
121
+ const statements = splitStatements(scan);
122
+ for (const stmt of statements) {
123
+ if (/^\s*DELETE\s+FROM\b/i.test(stmt) && !/\bWHERE\b/i.test(stmt)) {
124
+ errors.push("DELETE FROM without WHERE clause");
125
+ break;
126
+ }
127
+ }
128
+ for (const stmt of statements) {
129
+ if (/^\s*UPDATE\s+\w+(?:\.\w+)?\s+SET\b/i.test(stmt) && !/\bWHERE\b/i.test(stmt)) {
130
+ errors.push("UPDATE without WHERE clause — affects every row");
131
+ break;
132
+ }
119
133
  }
120
134
 
121
135
  // TRUNCATE (almost always wrong in migrations)
@@ -2,17 +2,44 @@
2
2
  // ~/.claude/hooks/pre-compact.js — commit STATE.md before context compaction.
3
3
  // PreCompact hook. Silent on failure — context compaction must never be blocked.
4
4
  // Cross-platform (Windows/macOS/Linux).
5
+ //
6
+ // BY DEFAULT this commit uses --no-verify + --no-gpg-sign. The auto-save is a
7
+ // framework bot commit, and pre-commit hooks that run full test suites would
8
+ // routinely fail (context compaction happens at any moment) and lose the
9
+ // STATE.md snapshot. But compliance-sensitive projects can opt into strict
10
+ // mode via ~/.claude/.qualia-config.json:
11
+ //
12
+ // {
13
+ // "pre_compact": {
14
+ // "respect_user_hooks": true,
15
+ // "respect_gpg_signing": true
16
+ // }
17
+ // }
18
+ //
19
+ // When either is true, the corresponding --no-* flag is dropped.
5
20
 
6
21
  const fs = require("fs");
7
22
  const path = require("path");
23
+ const os = require("os");
8
24
  const { spawnSync } = require("child_process");
9
25
 
10
26
  const _traceStart = Date.now();
11
27
 
12
28
  const STATE_FILE = path.join(".planning", "STATE.md");
29
+ const CONFIG_FILE = path.join(os.homedir(), ".claude", ".qualia-config.json");
30
+
31
+ function readCompactConfig() {
32
+ try {
33
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
34
+ return cfg.pre_compact || {};
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
13
39
 
14
40
  let _commitStatus = null;
15
41
  let _commitReason = "no-state-file";
42
+ let _commitFlags = null;
16
43
 
17
44
  try {
18
45
  if (fs.existsSync(STATE_FILE)) {
@@ -29,16 +56,17 @@ try {
29
56
  timeout: 3000,
30
57
  shell: process.platform === "win32",
31
58
  });
32
- // Bypass user pre-commit hooks and commit signing so the auto-save
33
- // never fails silently and STATE.md is always persisted before
34
- // context compaction. Attribute to the framework bot, not the user.
35
- const commitRes = spawnSync("git", [
36
- "commit",
37
- "--no-verify",
38
- "--no-gpg-sign",
39
- "--author=Qualia Framework <bot@qualia.solutions>",
40
- "-m", "state: pre-compaction save",
41
- ], {
59
+ const cfg = readCompactConfig();
60
+ const commitArgs = ["commit"];
61
+ if (!cfg.respect_user_hooks) commitArgs.push("--no-verify");
62
+ if (!cfg.respect_gpg_signing) commitArgs.push("--no-gpg-sign");
63
+ commitArgs.push("--author=Qualia Framework <bot@qualia.solutions>");
64
+ commitArgs.push("-m", "state: pre-compaction save");
65
+ _commitFlags = {
66
+ no_verify: !cfg.respect_user_hooks,
67
+ no_gpg_sign: !cfg.respect_gpg_signing,
68
+ };
69
+ const commitRes = spawnSync("git", commitArgs, {
42
70
  timeout: 5000,
43
71
  stdio: ["ignore", "ignore", "pipe"],
44
72
  encoding: "utf8",
@@ -72,5 +100,5 @@ function _trace(hookName, result, extra) {
72
100
  } catch {}
73
101
  }
74
102
 
75
- _trace("pre-compact", "allow", { commit_status: _commitStatus, commit_reason: _commitReason });
103
+ _trace("pre-compact", "allow", { commit_status: _commitStatus, commit_reason: _commitReason, commit_flags: _commitFlags });
76
104
  process.exit(0);
@@ -2,8 +2,7 @@
2
2
  // ~/.claude/hooks/pre-deploy-gate.js — quality gates before production deploy.
3
3
  // PreToolUse hook on `vercel --prod*` commands. Runs tsc, lint, tests, build,
4
4
  // then scans for service_role leaks in client code.
5
- // Exits 1 to BLOCK deploy (preserved for test compatibility; Claude Code's hook
6
- // protocol formally uses exit 2, but the framework's existing tests assert on 1).
5
+ // Exits 2 to BLOCK deploy (Claude Code PreToolUse hook contract).
7
6
  // Exits 0 to allow.
8
7
  // Cross-platform (Windows/macOS/Linux). No `grep` or `find` — pure Node.
9
8
 
@@ -43,7 +42,7 @@ function runGate(label, cmd, args, { required = true } = {}) {
43
42
  if (required) {
44
43
  console.error(`BLOCKED: ${label} errors. Fix before deploying.`);
45
44
  _trace("pre-deploy-gate", "block", { gate: label });
46
- process.exit(1);
45
+ process.exit(2);
47
46
  }
48
47
  return false;
49
48
  }
@@ -180,7 +179,7 @@ if (leaks.length > 0) {
180
179
  console.error(` ✗ ${f}`);
181
180
  }
182
181
  _trace("pre-deploy-gate", "block", { gate: "security", leaks: leaks.slice(0, 10) });
183
- process.exit(1);
182
+ process.exit(2);
184
183
  }
185
184
  console.log(" ✓ Security");
186
185
  console.log("⬢ All gates passed.");
package/hooks/pre-push.js CHANGED
@@ -85,9 +85,12 @@ function commitStamp() {
85
85
  "-m", `chore(track): ERP sync ${now}`,
86
86
  ]);
87
87
  if (commit.status !== 0) {
88
- // If commit failed (e.g., empty diff because git's auto-CRLF normalized
89
- // the only change to nothing), restore the file to keep the working tree
90
- // clean and move on. Not fatal.
88
+ // Commit failed (e.g., empty diff because git's auto-CRLF normalized the
89
+ // only change to nothing, or branch is in a detached/conflicted state).
90
+ // Unstage tracking.json and restore the working tree copy so the user's
91
+ // next manual commit isn't polluted by our aborted stamp.
92
+ try { git(["reset", "HEAD", "--", TRACKING]); } catch {}
93
+ try { if (raw != null) atomicWrite(TRACKING, raw); } catch {}
91
94
  return { skipped: "git-commit-failed", error: (commit.stderr || commit.stdout || "").trim() };
92
95
  }
93
96
  return { committed: true, sha: lastCommit, ts: now };
@@ -52,6 +52,14 @@ function getNextCommand() {
52
52
  }
53
53
  }
54
54
 
55
+ function readConfig() {
56
+ try {
57
+ return JSON.parse(fs.readFileSync(path.join(HOME, ".claude", ".qualia-config.json"), "utf8"));
58
+ } catch {
59
+ return {};
60
+ }
61
+ }
62
+
55
63
  function fallbackText() {
56
64
  // If qualia-ui.js is missing, emit plain text. Keeps the session informative
57
65
  // even on a broken install.
@@ -121,14 +129,6 @@ try {
121
129
  // Deliberately silent — hook must never fail
122
130
  }
123
131
 
124
- function readConfig() {
125
- try {
126
- return JSON.parse(fs.readFileSync(path.join(HOME, ".claude", ".qualia-config.json"), "utf8"));
127
- } catch {
128
- return {};
129
- }
130
- }
131
-
132
132
  function _trace(hookName, result, extra) {
133
133
  try {
134
134
  const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qualia-framework",
3
- "version": "4.0.0",
3
+ "version": "4.0.3",
4
4
  "description": "Claude Code workflow framework by Qualia Solutions. Plan, build, verify, ship.",
5
5
  "bin": {
6
6
  "qualia-framework": "./bin/cli.js"