qualia-framework 3.2.0 → 3.3.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.
Files changed (58) hide show
  1. package/CLAUDE.md +3 -4
  2. package/README.md +59 -23
  3. package/agents/plan-checker.md +158 -0
  4. package/agents/planner.md +52 -0
  5. package/agents/research-synthesizer.md +86 -0
  6. package/agents/researcher.md +119 -0
  7. package/agents/roadmapper.md +157 -0
  8. package/agents/verifier.md +180 -32
  9. package/bin/cli.js +403 -9
  10. package/bin/install.js +219 -70
  11. package/bin/qualia-ui.js +11 -11
  12. package/bin/state.js +200 -6
  13. package/bin/statusline.js +4 -4
  14. package/docs/erp-contract.md +161 -0
  15. package/hooks/branch-guard.js +23 -2
  16. package/hooks/migration-guard.js +23 -0
  17. package/hooks/pre-compact.js +20 -0
  18. package/hooks/pre-deploy-gate.js +39 -0
  19. package/hooks/pre-push.js +20 -0
  20. package/hooks/session-start.js +16 -43
  21. package/package.json +6 -4
  22. package/references/questioning.md +123 -0
  23. package/rules/infrastructure.md +87 -0
  24. package/skills/qualia/SKILL.md +1 -0
  25. package/skills/qualia-build/SKILL.md +18 -0
  26. package/skills/qualia-design/SKILL.md +14 -8
  27. package/skills/qualia-discuss/SKILL.md +115 -0
  28. package/skills/qualia-help/SKILL.md +60 -0
  29. package/skills/qualia-learn/SKILL.md +27 -4
  30. package/skills/qualia-map/SKILL.md +145 -0
  31. package/skills/qualia-milestone/SKILL.md +148 -0
  32. package/skills/qualia-new/SKILL.md +374 -229
  33. package/skills/qualia-plan/SKILL.md +135 -30
  34. package/skills/qualia-polish/SKILL.md +167 -117
  35. package/skills/qualia-report/SKILL.md +17 -8
  36. package/skills/qualia-research/SKILL.md +124 -0
  37. package/skills/qualia-review/SKILL.md +126 -41
  38. package/skills/qualia-test/SKILL.md +134 -0
  39. package/skills/qualia-verify/SKILL.md +1 -1
  40. package/templates/DESIGN.md +440 -102
  41. package/templates/help.html +476 -0
  42. package/templates/phase-context.md +48 -0
  43. package/templates/plan.md +14 -0
  44. package/templates/projects/ai-agent.md +55 -0
  45. package/templates/projects/mobile-app.md +56 -0
  46. package/templates/projects/voice-agent.md +55 -0
  47. package/templates/projects/website.md +58 -0
  48. package/templates/requirements.md +69 -0
  49. package/templates/research-project/ARCHITECTURE.md +70 -0
  50. package/templates/research-project/FEATURES.md +60 -0
  51. package/templates/research-project/PITFALLS.md +73 -0
  52. package/templates/research-project/STACK.md +51 -0
  53. package/templates/research-project/SUMMARY.md +86 -0
  54. package/templates/roadmap.md +71 -0
  55. package/tests/bin.test.sh +20 -6
  56. package/tests/hooks.test.sh +76 -7
  57. package/tests/runner.js +1915 -0
  58. package/tests/state.test.sh +189 -11
package/bin/state.js CHANGED
@@ -9,6 +9,17 @@ 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
 
12
+ // ─── Trace ──────────────────────────────────────────────
13
+ function _trace(event, data) {
14
+ try {
15
+ const traceDir = path.join(require("os").homedir(), ".claude", ".qualia-traces");
16
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
17
+ const entry = { hook: event, timestamp: new Date().toISOString(), ...data };
18
+ const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
19
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n");
20
+ } catch { /* trace failures must not disrupt state machine */ }
21
+ }
22
+
12
23
  // ─── Arg Parsing ─────────────────────────────────────────
13
24
  function parseArgs(argv) {
14
25
  const args = {};
@@ -186,6 +197,25 @@ const VALID_FROM = {
186
197
  done: ["handed_off"],
187
198
  };
188
199
 
200
+ // ─── Configurable Gap Cycle Limit ────────────────────────
201
+ function getGapCycleLimit() {
202
+ // Priority: tracking.json.gap_cycle_limit > PROJECT.md > default (2)
203
+ try {
204
+ const t = readTracking();
205
+ if (t && typeof t.gap_cycle_limit === "number" && t.gap_cycle_limit > 0) {
206
+ return t.gap_cycle_limit;
207
+ }
208
+ } catch {}
209
+
210
+ try {
211
+ const projectMd = fs.readFileSync(path.join(PLANNING, "PROJECT.md"), "utf8");
212
+ const match = projectMd.match(/^gap_cycle_limit:\s*(\d+)/m);
213
+ if (match) return parseInt(match[1]);
214
+ } catch {}
215
+
216
+ return 2; // default
217
+ }
218
+
189
219
  function checkPreconditions(current, target, opts) {
190
220
  const phase = parseInt(opts.phase) || current.phase;
191
221
 
@@ -207,6 +237,14 @@ function checkPreconditions(current, target, opts) {
207
237
  const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
208
238
  if (!fs.existsSync(planFile))
209
239
  return fail("MISSING_FILE", `Plan file not found: ${planFile}`);
240
+ // Validate plan content (not just existence)
241
+ const planContent = fs.readFileSync(planFile, "utf8");
242
+ const taskHeaders = planContent.match(/^## Task \d+/gm);
243
+ if (!taskHeaders || taskHeaders.length === 0)
244
+ return fail("INVALID_PLAN", "Plan file has no task headers (expected '## Task N')");
245
+ const doneWhenCount = (planContent.match(/\*\*Done when:\*\*/g) || []).length;
246
+ if (doneWhenCount < taskHeaders.length)
247
+ return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${doneWhenCount} 'Done when:' entries`);
210
248
  }
211
249
 
212
250
  if (target === "verified") {
@@ -228,14 +266,15 @@ function checkPreconditions(current, target, opts) {
228
266
  return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
229
267
  }
230
268
 
231
- // Gap-closure circuit breaker
269
+ // Gap-closure circuit breaker (configurable limit)
232
270
  if (target === "planned" && current.status === "verified") {
233
271
  const t = readTracking() || {};
234
272
  const cycles = (t.gap_cycles || {})[String(phase)] || 0;
235
- if (cycles >= 2) {
273
+ const limit = getGapCycleLimit();
274
+ if (cycles >= limit) {
236
275
  return fail(
237
276
  "GAP_CYCLE_LIMIT",
238
- `Phase ${phase} has failed verification ${cycles} times. Escalate to Fawzi or re-plan from scratch.`
277
+ `Phase ${phase} has failed verification ${cycles} times (limit: ${limit}). Escalate to Fawzi or re-plan from scratch.`
239
278
  );
240
279
  }
241
280
  }
@@ -294,6 +333,7 @@ function cmdCheck(opts) {
294
333
  assigned_to: s.assigned_to,
295
334
  verification: t.verification || "pending",
296
335
  gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
336
+ gap_cycle_limit: getGapCycleLimit(),
297
337
  tasks_done: t.tasks_done || 0,
298
338
  tasks_total: t.tasks_total || 0,
299
339
  deployed_url: t.deployed_url || "",
@@ -331,7 +371,6 @@ function cmdTransition(opts) {
331
371
 
332
372
  // Special: note/activity (no status change)
333
373
  if (target === "note" || target === "activity") {
334
- const now = new Date().toISOString().split("T")[0];
335
374
  if (opts.notes) t.notes = opts.notes;
336
375
  t.last_updated = new Date().toISOString();
337
376
  writeTracking(t);
@@ -353,7 +392,16 @@ function cmdTransition(opts) {
353
392
  target,
354
393
  { ...opts, phase }
355
394
  );
356
- if (!check.ok) return output(check);
395
+ if (!check.ok) {
396
+ // Force only bypasses status-ordering errors (PRECONDITION_FAILED, GAP_CYCLE_LIMIT).
397
+ // Never bypass MISSING_FILE, MISSING_ARG, INVALID_PLAN — those cause broken state.
398
+ const forceableErrors = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT"];
399
+ if (opts.force && forceableErrors.includes(check.error)) {
400
+ console.error(`WARNING: Forcing transition despite: ${check.message}`);
401
+ } else {
402
+ return output(check);
403
+ }
404
+ }
357
405
 
358
406
  const prevStatus = s.status;
359
407
 
@@ -432,6 +480,18 @@ function cmdTransition(opts) {
432
480
  return output(fail("WRITE_ERROR", e.message));
433
481
  }
434
482
 
483
+ // Skill outcome scoring — log transition for analytics
484
+ _trace("state-transition", {
485
+ result: "allow",
486
+ phase: s.phase,
487
+ status: s.status,
488
+ previous_status: prevStatus,
489
+ verification: t.verification,
490
+ gap_closure: prevStatus === "verified" && target === "planned",
491
+ duration_ms: 0,
492
+ extra: { verification: t.verification, gap_closure: prevStatus === "verified" && target === "planned" }
493
+ });
494
+
435
495
  output({
436
496
  ok: true,
437
497
  phase: s.phase,
@@ -597,6 +657,137 @@ function cmdFix(opts) {
597
657
  });
598
658
  }
599
659
 
660
+ function cmdValidatePlan(opts) {
661
+ const phase = parseInt(opts.phase) || 1;
662
+ const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
663
+
664
+ if (!fs.existsSync(planFile)) {
665
+ return output(fail("MISSING_FILE", `Plan file not found: ${planFile}`));
666
+ }
667
+
668
+ const content = fs.readFileSync(planFile, "utf8");
669
+ const errors = [];
670
+
671
+ // Check frontmatter exists
672
+ if (!/^---\n/.test(content)) {
673
+ errors.push("Missing frontmatter (---) at start of file");
674
+ }
675
+
676
+ // Check task count > 0
677
+ const taskHeaders = content.match(/^## Task \d+/gm);
678
+ if (!taskHeaders || taskHeaders.length === 0) {
679
+ errors.push("No task headers found (expected '## Task N — title')");
680
+ }
681
+
682
+ // Check "Done when" exists for each task
683
+ const taskCount = taskHeaders ? taskHeaders.length : 0;
684
+ const doneWhenCount = (content.match(/\*\*Done when:\*\*/g) || []).length;
685
+ if (doneWhenCount < taskCount) {
686
+ errors.push(
687
+ `${taskCount} tasks but only ${doneWhenCount} 'Done when:' entries`
688
+ );
689
+ }
690
+
691
+ // Check Success Criteria section exists
692
+ if (!/## Success Criteria/m.test(content)) {
693
+ errors.push("Missing '## Success Criteria' section");
694
+ }
695
+
696
+ // Check goal in frontmatter
697
+ if (!/^goal:/m.test(content)) {
698
+ errors.push("Missing 'goal:' in frontmatter");
699
+ }
700
+
701
+ // ─── Verification Contract Validation (non-blocking) ────
702
+ const warnings = [];
703
+ const VALID_CHECK_TYPES = ["file-exists", "grep-match", "command-exit", "behavioral"];
704
+ let contractCount = 0;
705
+
706
+ if (/^## Verification Contract/m.test(content)) {
707
+ // Extract the contract section (from header to next ## or end of file)
708
+ const contractSectionMatch = content.match(
709
+ /^## Verification Contract\s*\n([\s\S]+)/m
710
+ );
711
+ if (contractSectionMatch) {
712
+ // Trim at the next ## heading that isn't ### (i.e., a new top-level section)
713
+ let contractSection = contractSectionMatch[1];
714
+ const nextH2 = contractSection.search(/\n## (?!#)/);
715
+ if (nextH2 !== -1) contractSection = contractSection.substring(0, nextH2);
716
+ // Each contract starts with ### Contract for Task N
717
+ const contractBlocks = contractSection.match(/^### Contract for Task \d+/gm);
718
+ contractCount = contractBlocks ? contractBlocks.length : 0;
719
+
720
+ if (contractCount === 0) {
721
+ warnings.push("Verification Contract section exists but contains no contract blocks (expected '### Contract for Task N')");
722
+ } else {
723
+ // Split into individual contract blocks for validation
724
+ const blockSplits = contractSection.split(/^(?=### Contract for Task \d+)/m).filter(Boolean);
725
+ for (const block of blockSplits) {
726
+ const taskNumMatch = block.match(/^### Contract for Task (\d+)/);
727
+ if (!taskNumMatch) continue;
728
+ const taskNum = taskNumMatch[1];
729
+
730
+ const checkTypeMatch = block.match(/\*\*Check type:\*\*\s*(.+)/);
731
+ const hasCommand = /\*\*Command:\*\*/.test(block);
732
+ const hasExpected = /\*\*Expected:\*\*/.test(block);
733
+ const hasFailIf = /\*\*Fail if:\*\*/.test(block);
734
+
735
+ if (!checkTypeMatch) {
736
+ warnings.push(`Contract for Task ${taskNum}: missing 'Check type'`);
737
+ } else {
738
+ const checkType = checkTypeMatch[1].trim().toLowerCase();
739
+ if (!VALID_CHECK_TYPES.includes(checkType)) {
740
+ warnings.push(
741
+ `Contract for Task ${taskNum}: invalid check type '${checkType}' (valid: ${VALID_CHECK_TYPES.join(", ")})`
742
+ );
743
+ }
744
+ // behavioral type doesn't require Command or Expected
745
+ const isBehavioral = checkType === "behavioral";
746
+ if (!isBehavioral && !hasCommand) {
747
+ warnings.push(`Contract for Task ${taskNum}: missing 'Command' (required for ${checkType})`);
748
+ }
749
+ if (!isBehavioral && !hasExpected) {
750
+ warnings.push(`Contract for Task ${taskNum}: missing 'Expected' (required for ${checkType})`);
751
+ }
752
+ }
753
+
754
+ if (!hasFailIf) {
755
+ warnings.push(`Contract for Task ${taskNum}: missing 'Fail if'`);
756
+ }
757
+ }
758
+ }
759
+
760
+ // Warn if contract count < task count
761
+ if (taskCount > 0 && contractCount > 0 && contractCount < taskCount) {
762
+ warnings.push(
763
+ `Only ${contractCount} contract(s) for ${taskCount} task(s) — not all tasks have verification contracts`
764
+ );
765
+ }
766
+ }
767
+ }
768
+
769
+ if (errors.length > 0) {
770
+ return output({
771
+ ok: false,
772
+ error: "PLAN_VALIDATION_FAILED",
773
+ phase,
774
+ errors,
775
+ warnings: warnings.length > 0 ? warnings : undefined,
776
+ message: `Plan file has ${errors.length} issue(s)`,
777
+ });
778
+ }
779
+
780
+ output({
781
+ ok: true,
782
+ action: "validate-plan",
783
+ phase,
784
+ task_count: taskCount,
785
+ done_when_count: doneWhenCount,
786
+ contract_count: contractCount,
787
+ warnings: warnings.length > 0 ? warnings : undefined,
788
+ });
789
+ }
790
+
600
791
  // ─── Output ──────────────────────────────────────────────
601
792
  function output(obj) {
602
793
  console.log(JSON.stringify(obj, null, 2));
@@ -620,11 +811,14 @@ switch (cmd) {
620
811
  case "fix":
621
812
  cmdFix(opts);
622
813
  break;
814
+ case "validate-plan":
815
+ cmdValidatePlan(opts);
816
+ break;
623
817
  default:
624
818
  output(
625
819
  fail(
626
820
  "UNKNOWN_COMMAND",
627
- `Usage: state.js <check|transition|init|fix> [--options]`
821
+ `Usage: state.js <check|transition|init|fix|validate-plan> [--options]`
628
822
  )
629
823
  );
630
824
  }
package/bin/statusline.js CHANGED
@@ -224,11 +224,11 @@ try {
224
224
  if (AGENT) LINE1 += ` ${DIM}│${RESET} ${TEAL}⚡${AGENT}${RESET}`;
225
225
  if (WORKTREE) LINE1 += ` ${DIM}│${RESET} ${TEAL_DIM}⎇ ${WORKTREE}${RESET}`;
226
226
  if (PHASE_INFO) LINE1 += ` ${DIM}│${RESET} ${PHASE_INFO}`;
227
- // Memory, hooks, skills — context indicators
227
+ // Memory, hooks, skills — context indicators with labels
228
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}`);
229
+ if (MEMORY_COUNT > 0) contextParts.push(`${DIM}mem${RESET} ${TEAL}${MEMORY_COUNT}${RESET}`);
230
+ if (HOOKS_COUNT > 0) contextParts.push(`${DIM}hooks${RESET} ${TEAL_GLOW}${HOOKS_COUNT}${RESET}`);
231
+ if (SKILLS_COUNT > 0) contextParts.push(`${DIM}skills${RESET} ${TEAL_DIM}${SKILLS_COUNT}${RESET}`);
232
232
  if (contextParts.length > 0) {
233
233
  LINE1 += ` ${DIM}│${RESET} ${contextParts.join(` ${DIM}·${RESET} `)}`;
234
234
  }
@@ -0,0 +1,161 @@
1
+ # ERP API Contract
2
+
3
+ The Qualia Framework optionally uploads session reports to the company ERP at `https://portal.qualiasolutions.net`. This document specifies the API shape.
4
+
5
+ ## Configuration
6
+
7
+ Stored in `~/.claude/.qualia-config.json`:
8
+
9
+ ```json
10
+ {
11
+ "erp": {
12
+ "enabled": true,
13
+ "url": "https://portal.qualiasolutions.net",
14
+ "api_key_file": ".erp-api-key"
15
+ }
16
+ }
17
+ ```
18
+
19
+ The API key is read from `~/.claude/.erp-api-key` (file mode 0600).
20
+
21
+ ## Endpoints
22
+
23
+ ### POST /api/v1/reports
24
+
25
+ Upload a session report.
26
+
27
+ **Headers:**
28
+ ```
29
+ Authorization: Bearer <api-key>
30
+ Content-Type: application/json
31
+ ```
32
+
33
+ **Request Body:**
34
+ ```json
35
+ {
36
+ "project": "client-project-name",
37
+ "client": "Client Name",
38
+ "phase": 2,
39
+ "phase_name": "Authentication & Dashboard",
40
+ "total_phases": 4,
41
+ "status": "built",
42
+ "tasks_done": 5,
43
+ "tasks_total": 5,
44
+ "verification": "pass",
45
+ "gap_cycles": 0,
46
+ "deployed_url": "https://client.vercel.app",
47
+ "session_duration_minutes": 45,
48
+ "commits": ["abc1234", "def5678"],
49
+ "notes": "Completed auth flow, dashboard layout, and API routes.",
50
+ "submitted_by": "Fawzi Goussous",
51
+ "submitted_at": "2026-04-12T14:30:00Z"
52
+ }
53
+ ```
54
+
55
+ **Response (200 OK):**
56
+ ```json
57
+ {
58
+ "ok": true,
59
+ "report_id": "rpt_abc123def456",
60
+ "message": "Report received"
61
+ }
62
+ ```
63
+
64
+ **Response (401 Unauthorized):**
65
+ ```json
66
+ {
67
+ "ok": false,
68
+ "error": "INVALID_API_KEY",
69
+ "message": "API key is invalid or expired"
70
+ }
71
+ ```
72
+
73
+ **Response (422 Unprocessable Entity):**
74
+ ```json
75
+ {
76
+ "ok": false,
77
+ "error": "VALIDATION_FAILED",
78
+ "message": "Missing required field: project"
79
+ }
80
+ ```
81
+
82
+ ### GET /api/v1/reports/:project
83
+
84
+ Retrieve reports for a project.
85
+
86
+ **Headers:**
87
+ ```
88
+ Authorization: Bearer <api-key>
89
+ ```
90
+
91
+ **Response (200 OK):**
92
+ ```json
93
+ {
94
+ "ok": true,
95
+ "reports": [
96
+ {
97
+ "report_id": "rpt_abc123def456",
98
+ "phase": 2,
99
+ "status": "built",
100
+ "submitted_at": "2026-04-12T14:30:00Z",
101
+ "submitted_by": "Fawzi Goussous"
102
+ }
103
+ ]
104
+ }
105
+ ```
106
+
107
+ ### GET /api/v1/tracking/:project
108
+
109
+ Retrieve current tracking state (same shape as tracking.json).
110
+
111
+ **Headers:**
112
+ ```
113
+ Authorization: Bearer <api-key>
114
+ ```
115
+
116
+ **Response (200 OK):**
117
+ ```json
118
+ {
119
+ "ok": true,
120
+ "tracking": {
121
+ "project": "client-project-name",
122
+ "phase": 2,
123
+ "total_phases": 4,
124
+ "status": "built",
125
+ "last_updated": "2026-04-12T14:30:00Z"
126
+ }
127
+ }
128
+ ```
129
+
130
+ ## Behavior
131
+
132
+ - When `erp.enabled` is `false`, `/qualia-report` skips the upload silently.
133
+ - When the API key file is missing or empty, the upload is skipped with a warning.
134
+ - Network failures are non-blocking — the report is saved locally regardless.
135
+ - The ERP reads `tracking.json` directly from git for real-time status (no API call needed for passive monitoring).
136
+ - Reports are append-only — no update or delete endpoints exist.
137
+
138
+ ## Required Fields
139
+
140
+ | Field | Type | Required | Description |
141
+ |-------|------|----------|-------------|
142
+ | project | string | yes | Project slug from tracking.json |
143
+ | phase | number | yes | Current phase number |
144
+ | status | string | yes | Current status (setup, planned, built, verified, etc.) |
145
+ | submitted_by | string | yes | Team member name |
146
+ | submitted_at | string | yes | ISO 8601 timestamp |
147
+
148
+ All other fields are optional but recommended for complete reporting.
149
+
150
+ ## Rate Limits
151
+
152
+ - 60 requests per minute per API key
153
+ - Report body max size: 64KB
154
+ - No batch endpoint — one report per request
155
+
156
+ ## Security
157
+
158
+ - API keys are per-user, not per-project
159
+ - Keys expire after 90 days (re-issue via Fawzi)
160
+ - All traffic is HTTPS-only
161
+ - No PII beyond team member names is transmitted
@@ -10,11 +10,30 @@ const path = require("path");
10
10
  const os = require("os");
11
11
  const { spawnSync } = require("child_process");
12
12
 
13
+ const _traceStart = Date.now();
14
+
13
15
  const CONFIG = path.join(os.homedir(), ".claude", ".qualia-config.json");
14
16
 
17
+ function _trace(hookName, result, extra) {
18
+ try {
19
+ const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
20
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
21
+ const entry = {
22
+ hook: hookName,
23
+ result,
24
+ timestamp: new Date().toISOString(),
25
+ duration_ms: Date.now() - _traceStart,
26
+ ...extra,
27
+ };
28
+ const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
29
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n");
30
+ } catch {}
31
+ }
32
+
15
33
  function fail(msg) {
16
34
  console.log(msg);
17
- process.exit(1);
35
+ _trace("branch-guard", "block", { reason: msg });
36
+ process.exit(2);
18
37
  }
19
38
 
20
39
  let role = "";
@@ -40,8 +59,10 @@ if (branch === "main" || branch === "master") {
40
59
  if (role !== "OWNER") {
41
60
  console.log(`BLOCKED: Employees cannot push to ${branch}. Create a feature branch first.`);
42
61
  console.log("Run: git checkout -b feature/your-feature-name");
43
- process.exit(1);
62
+ _trace("branch-guard", "block", { reason: `non-owner push to ${branch}` });
63
+ process.exit(2);
44
64
  }
45
65
  }
46
66
 
67
+ _trace("branch-guard", "allow");
47
68
  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");
@@ -20,8 +22,27 @@ const ti = input.tool_input || {};
20
22
  const file = String(ti.file_path || "").replace(/\\/g, "/");
21
23
  const content = String(ti.content || ti.new_string || "");
22
24
 
25
+ function _trace(hookName, result, extra) {
26
+ try {
27
+ const os = require("os");
28
+ const path = require("path");
29
+ const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
30
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
31
+ const entry = {
32
+ hook: hookName,
33
+ result,
34
+ timestamp: new Date().toISOString(),
35
+ duration_ms: Date.now() - _traceStart,
36
+ ...extra,
37
+ };
38
+ const filePath = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
39
+ fs.appendFileSync(filePath, JSON.stringify(entry) + "\n");
40
+ } catch {}
41
+ }
42
+
23
43
  // Only inspect migration/SQL files
24
44
  if (!/migration|migrate|\.sql$/i.test(file)) {
45
+ _trace("migration-guard", "allow", { reason: "non-migration file" });
25
46
  process.exit(0);
26
47
  }
27
48
 
@@ -54,7 +75,9 @@ if (errors.length > 0) {
54
75
  }
55
76
  console.log("");
56
77
  console.log("Fix these before proceeding. If intentional, ask Fawzi to approve.");
78
+ _trace("migration-guard", "block", { errors });
57
79
  process.exit(2);
58
80
  }
59
81
 
82
+ _trace("migration-guard", "allow");
60
83
  process.exit(0);
@@ -7,6 +7,8 @@ const fs = require("fs");
7
7
  const path = require("path");
8
8
  const { spawnSync } = require("child_process");
9
9
 
10
+ const _traceStart = Date.now();
11
+
10
12
  const STATE_FILE = path.join(".planning", "STATE.md");
11
13
 
12
14
  try {
@@ -29,4 +31,22 @@ try {
29
31
  // Silent — never block compaction
30
32
  }
31
33
 
34
+ function _trace(hookName, result, extra) {
35
+ try {
36
+ const os = require("os");
37
+ const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
38
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
39
+ const entry = {
40
+ hook: hookName,
41
+ result,
42
+ timestamp: new Date().toISOString(),
43
+ duration_ms: Date.now() - _traceStart,
44
+ ...extra,
45
+ };
46
+ const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
47
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n");
48
+ } catch {}
49
+ }
50
+
51
+ _trace("pre-compact", "allow");
32
52
  process.exit(0);
@@ -9,6 +9,25 @@ const fs = require("fs");
9
9
  const path = require("path");
10
10
  const { spawnSync } = require("child_process");
11
11
 
12
+ const _traceStart = Date.now();
13
+
14
+ function _trace(hookName, result, extra) {
15
+ try {
16
+ const os = require("os");
17
+ const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
18
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
19
+ const entry = {
20
+ hook: hookName,
21
+ result,
22
+ timestamp: new Date().toISOString(),
23
+ duration_ms: Date.now() - _traceStart,
24
+ ...extra,
25
+ };
26
+ const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
27
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n");
28
+ } catch {}
29
+ }
30
+
12
31
  function runGate(label, cmd, args, { required = true } = {}) {
13
32
  const r = spawnSync(cmd, args, {
14
33
  stdio: "ignore",
@@ -21,6 +40,7 @@ function runGate(label, cmd, args, { required = true } = {}) {
21
40
  }
22
41
  if (required) {
23
42
  console.log(`BLOCKED: ${label} errors. Fix before deploying.`);
43
+ _trace("pre-deploy-gate", "block", { gate: label });
24
44
  process.exit(1);
25
45
  }
26
46
  return false;
@@ -60,10 +80,27 @@ function scanServiceRoleLeaks() {
60
80
  const leaks = [];
61
81
  for (const root of roots) {
62
82
  for (const file of walk(root)) {
83
+ // --- Path-based skips (no I/O needed) ---
84
+
63
85
  // Skip server-only files (convention: *.server.ts, server/ dirs)
64
86
  if (/\.server\.|[\\/]server[\\/]/.test(file)) continue;
87
+
88
+ // Skip App Router route handlers (always server-side)
89
+ if (/[\\/]route\.(ts|tsx|js|jsx|mjs)$/.test(file)) continue;
90
+
91
+ // Skip middleware (always server-side)
92
+ if (/[\\/]middleware\.(ts|tsx|js|jsx|mjs)$/.test(file)) continue;
93
+
94
+ // Skip files in app/api/ directory (always server-side)
95
+ if (/[\\/]app[\\/]api[\\/]/.test(file)) continue;
96
+
97
+ // --- Content-based checks (requires reading file) ---
65
98
  try {
66
99
  const content = fs.readFileSync(file, "utf8");
100
+
101
+ // Skip files with "use server" directive (Server Actions / Server Components)
102
+ if (/^["']use server["']/m.test(content)) continue;
103
+
67
104
  if (/service_role/.test(content)) {
68
105
  leaks.push(file);
69
106
  }
@@ -102,9 +139,11 @@ if (leaks.length > 0) {
102
139
  for (const f of leaks.slice(0, 10)) {
103
140
  console.log(` ✗ ${f}`);
104
141
  }
142
+ _trace("pre-deploy-gate", "block", { gate: "security", leaks: leaks.slice(0, 10) });
105
143
  process.exit(1);
106
144
  }
107
145
  console.log(" ✓ Security");
108
146
  console.log("⬢ All gates passed.");
109
147
 
148
+ _trace("pre-deploy-gate", "allow");
110
149
  process.exit(0);
package/hooks/pre-push.js CHANGED
@@ -8,6 +8,8 @@ const fs = require("fs");
8
8
  const path = require("path");
9
9
  const { spawnSync } = require("child_process");
10
10
 
11
+ const _traceStart = Date.now();
12
+
11
13
  const TRACKING = path.join(".planning", "tracking.json");
12
14
 
13
15
  try {
@@ -30,4 +32,22 @@ try {
30
32
  process.stderr.write(`WARNING: tracking sync failed: ${err.message}\n`);
31
33
  }
32
34
 
35
+ function _trace(hookName, result, extra) {
36
+ try {
37
+ const os = require("os");
38
+ const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
39
+ if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
40
+ const entry = {
41
+ hook: hookName,
42
+ result,
43
+ timestamp: new Date().toISOString(),
44
+ duration_ms: Date.now() - _traceStart,
45
+ ...extra,
46
+ };
47
+ const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
48
+ fs.appendFileSync(file, JSON.stringify(entry) + "\n");
49
+ } catch {}
50
+ }
51
+
52
+ _trace("pre-push", "allow");
33
53
  process.exit(0);