qualia-framework-v2 2.10.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +1 -3
- package/README.md +11 -6
- package/agents/planner.md +52 -0
- package/agents/verifier.md +120 -19
- package/bin/cli.js +149 -0
- package/bin/install.js +30 -25
- package/bin/qualia-ui.js +1 -1
- package/bin/state.js +106 -6
- package/hooks/auto-update.js +27 -2
- package/hooks/block-env-edit.js +22 -0
- package/hooks/branch-guard.js +23 -2
- package/hooks/migration-guard.js +23 -0
- package/hooks/pre-compact.js +20 -0
- package/hooks/pre-deploy-gate.js +39 -0
- package/hooks/pre-push.js +20 -0
- package/hooks/session-start.js +19 -0
- package/package.json +1 -1
- package/skills/qualia/SKILL.md +1 -0
- package/skills/qualia-build/SKILL.md +18 -0
- package/skills/qualia-learn/SKILL.md +27 -4
- package/skills/qualia-polish/SKILL.md +154 -117
- package/skills/qualia-report/SKILL.md +17 -8
- package/skills/qualia-review/SKILL.md +126 -41
- package/skills/qualia-test/SKILL.md +134 -0
- package/skills/qualia-verify/SKILL.md +1 -1
- package/templates/plan.md +14 -0
- package/tests/bin.test.sh +15 -1
- package/tests/hooks.test.sh +53 -7
- package/tests/state.test.sh +189 -11
package/bin/state.js
CHANGED
|
@@ -186,6 +186,25 @@ const VALID_FROM = {
|
|
|
186
186
|
done: ["handed_off"],
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
// ─── Configurable Gap Cycle Limit ────────────────────────
|
|
190
|
+
function getGapCycleLimit() {
|
|
191
|
+
// Priority: tracking.json.gap_cycle_limit > PROJECT.md > default (2)
|
|
192
|
+
try {
|
|
193
|
+
const t = readTracking();
|
|
194
|
+
if (t && typeof t.gap_cycle_limit === "number" && t.gap_cycle_limit > 0) {
|
|
195
|
+
return t.gap_cycle_limit;
|
|
196
|
+
}
|
|
197
|
+
} catch {}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const projectMd = fs.readFileSync(path.join(PLANNING, "PROJECT.md"), "utf8");
|
|
201
|
+
const match = projectMd.match(/^gap_cycle_limit:\s*(\d+)/m);
|
|
202
|
+
if (match) return parseInt(match[1]);
|
|
203
|
+
} catch {}
|
|
204
|
+
|
|
205
|
+
return 2; // default
|
|
206
|
+
}
|
|
207
|
+
|
|
189
208
|
function checkPreconditions(current, target, opts) {
|
|
190
209
|
const phase = parseInt(opts.phase) || current.phase;
|
|
191
210
|
|
|
@@ -207,6 +226,14 @@ function checkPreconditions(current, target, opts) {
|
|
|
207
226
|
const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
|
|
208
227
|
if (!fs.existsSync(planFile))
|
|
209
228
|
return fail("MISSING_FILE", `Plan file not found: ${planFile}`);
|
|
229
|
+
// Validate plan content (not just existence)
|
|
230
|
+
const planContent = fs.readFileSync(planFile, "utf8");
|
|
231
|
+
const taskHeaders = planContent.match(/^## Task \d+/gm);
|
|
232
|
+
if (!taskHeaders || taskHeaders.length === 0)
|
|
233
|
+
return fail("INVALID_PLAN", "Plan file has no task headers (expected '## Task N')");
|
|
234
|
+
const doneWhenCount = (planContent.match(/\*\*Done when:\*\*/g) || []).length;
|
|
235
|
+
if (doneWhenCount < taskHeaders.length)
|
|
236
|
+
return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${doneWhenCount} 'Done when:' entries`);
|
|
210
237
|
}
|
|
211
238
|
|
|
212
239
|
if (target === "verified") {
|
|
@@ -228,14 +255,15 @@ function checkPreconditions(current, target, opts) {
|
|
|
228
255
|
return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
|
|
229
256
|
}
|
|
230
257
|
|
|
231
|
-
// Gap-closure circuit breaker
|
|
258
|
+
// Gap-closure circuit breaker (configurable limit)
|
|
232
259
|
if (target === "planned" && current.status === "verified") {
|
|
233
260
|
const t = readTracking() || {};
|
|
234
261
|
const cycles = (t.gap_cycles || {})[String(phase)] || 0;
|
|
235
|
-
|
|
262
|
+
const limit = getGapCycleLimit();
|
|
263
|
+
if (cycles >= limit) {
|
|
236
264
|
return fail(
|
|
237
265
|
"GAP_CYCLE_LIMIT",
|
|
238
|
-
`Phase ${phase} has failed verification ${cycles} times. Escalate to Fawzi or re-plan from scratch.`
|
|
266
|
+
`Phase ${phase} has failed verification ${cycles} times (limit: ${limit}). Escalate to Fawzi or re-plan from scratch.`
|
|
239
267
|
);
|
|
240
268
|
}
|
|
241
269
|
}
|
|
@@ -294,6 +322,7 @@ function cmdCheck(opts) {
|
|
|
294
322
|
assigned_to: s.assigned_to,
|
|
295
323
|
verification: t.verification || "pending",
|
|
296
324
|
gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
|
|
325
|
+
gap_cycle_limit: getGapCycleLimit(),
|
|
297
326
|
tasks_done: t.tasks_done || 0,
|
|
298
327
|
tasks_total: t.tasks_total || 0,
|
|
299
328
|
deployed_url: t.deployed_url || "",
|
|
@@ -331,7 +360,6 @@ function cmdTransition(opts) {
|
|
|
331
360
|
|
|
332
361
|
// Special: note/activity (no status change)
|
|
333
362
|
if (target === "note" || target === "activity") {
|
|
334
|
-
const now = new Date().toISOString().split("T")[0];
|
|
335
363
|
if (opts.notes) t.notes = opts.notes;
|
|
336
364
|
t.last_updated = new Date().toISOString();
|
|
337
365
|
writeTracking(t);
|
|
@@ -353,7 +381,16 @@ function cmdTransition(opts) {
|
|
|
353
381
|
target,
|
|
354
382
|
{ ...opts, phase }
|
|
355
383
|
);
|
|
356
|
-
if (!check.ok)
|
|
384
|
+
if (!check.ok) {
|
|
385
|
+
// Force only bypasses status-ordering errors (PRECONDITION_FAILED, GAP_CYCLE_LIMIT).
|
|
386
|
+
// Never bypass MISSING_FILE, MISSING_ARG, INVALID_PLAN — those cause broken state.
|
|
387
|
+
const forceableErrors = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT"];
|
|
388
|
+
if (opts.force && forceableErrors.includes(check.error)) {
|
|
389
|
+
console.error(`WARNING: Forcing transition despite: ${check.message}`);
|
|
390
|
+
} else {
|
|
391
|
+
return output(check);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
357
394
|
|
|
358
395
|
const prevStatus = s.status;
|
|
359
396
|
|
|
@@ -597,6 +634,66 @@ function cmdFix(opts) {
|
|
|
597
634
|
});
|
|
598
635
|
}
|
|
599
636
|
|
|
637
|
+
function cmdValidatePlan(opts) {
|
|
638
|
+
const phase = parseInt(opts.phase) || 1;
|
|
639
|
+
const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
|
|
640
|
+
|
|
641
|
+
if (!fs.existsSync(planFile)) {
|
|
642
|
+
return output(fail("MISSING_FILE", `Plan file not found: ${planFile}`));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const content = fs.readFileSync(planFile, "utf8");
|
|
646
|
+
const errors = [];
|
|
647
|
+
|
|
648
|
+
// Check frontmatter exists
|
|
649
|
+
if (!/^---\n/.test(content)) {
|
|
650
|
+
errors.push("Missing frontmatter (---) at start of file");
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Check task count > 0
|
|
654
|
+
const taskHeaders = content.match(/^## Task \d+/gm);
|
|
655
|
+
if (!taskHeaders || taskHeaders.length === 0) {
|
|
656
|
+
errors.push("No task headers found (expected '## Task N — title')");
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// Check "Done when" exists for each task
|
|
660
|
+
const taskCount = taskHeaders ? taskHeaders.length : 0;
|
|
661
|
+
const doneWhenCount = (content.match(/\*\*Done when:\*\*/g) || []).length;
|
|
662
|
+
if (doneWhenCount < taskCount) {
|
|
663
|
+
errors.push(
|
|
664
|
+
`${taskCount} tasks but only ${doneWhenCount} 'Done when:' entries`
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// Check Success Criteria section exists
|
|
669
|
+
if (!/## Success Criteria/m.test(content)) {
|
|
670
|
+
errors.push("Missing '## Success Criteria' section");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Check goal in frontmatter
|
|
674
|
+
if (!/^goal:/m.test(content)) {
|
|
675
|
+
errors.push("Missing 'goal:' in frontmatter");
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (errors.length > 0) {
|
|
679
|
+
return output({
|
|
680
|
+
ok: false,
|
|
681
|
+
error: "PLAN_VALIDATION_FAILED",
|
|
682
|
+
phase,
|
|
683
|
+
errors,
|
|
684
|
+
message: `Plan file has ${errors.length} issue(s)`,
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
output({
|
|
689
|
+
ok: true,
|
|
690
|
+
action: "validate-plan",
|
|
691
|
+
phase,
|
|
692
|
+
task_count: taskCount,
|
|
693
|
+
done_when_count: doneWhenCount,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
600
697
|
// ─── Output ──────────────────────────────────────────────
|
|
601
698
|
function output(obj) {
|
|
602
699
|
console.log(JSON.stringify(obj, null, 2));
|
|
@@ -620,11 +717,14 @@ switch (cmd) {
|
|
|
620
717
|
case "fix":
|
|
621
718
|
cmdFix(opts);
|
|
622
719
|
break;
|
|
720
|
+
case "validate-plan":
|
|
721
|
+
cmdValidatePlan(opts);
|
|
722
|
+
break;
|
|
623
723
|
default:
|
|
624
724
|
output(
|
|
625
725
|
fail(
|
|
626
726
|
"UNKNOWN_COMMAND",
|
|
627
|
-
`Usage: state.js <check|transition|init|fix> [--options]`
|
|
727
|
+
`Usage: state.js <check|transition|init|fix|validate-plan> [--options]`
|
|
628
728
|
)
|
|
629
729
|
);
|
|
630
730
|
}
|
package/hooks/auto-update.js
CHANGED
|
@@ -8,23 +8,43 @@ const path = require("path");
|
|
|
8
8
|
const os = require("os");
|
|
9
9
|
const { spawn, spawnSync } = require("child_process");
|
|
10
10
|
|
|
11
|
+
const _traceStart = Date.now();
|
|
12
|
+
|
|
11
13
|
const CLAUDE_DIR = path.join(os.homedir(), ".claude");
|
|
12
14
|
const CACHE_FILE = path.join(CLAUDE_DIR, ".qualia-last-update-check");
|
|
13
15
|
const CONFIG_FILE = path.join(CLAUDE_DIR, ".qualia-config.json");
|
|
14
16
|
const LOCK_FILE = path.join(CLAUDE_DIR, ".qualia-updating");
|
|
15
17
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
16
18
|
|
|
19
|
+
function _trace(hookName, result, extra) {
|
|
20
|
+
try {
|
|
21
|
+
const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
|
|
22
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
23
|
+
const entry = {
|
|
24
|
+
hook: hookName,
|
|
25
|
+
result,
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
duration_ms: Date.now() - _traceStart,
|
|
28
|
+
...extra,
|
|
29
|
+
};
|
|
30
|
+
const traceFile = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
31
|
+
fs.appendFileSync(traceFile, JSON.stringify(entry) + "\n");
|
|
32
|
+
} catch {}
|
|
33
|
+
}
|
|
34
|
+
|
|
17
35
|
try {
|
|
18
36
|
// Fast path: recently checked
|
|
19
37
|
if (fs.existsSync(CACHE_FILE)) {
|
|
20
38
|
const last = Number(fs.readFileSync(CACHE_FILE, "utf8")) || 0;
|
|
21
39
|
if (Date.now() - last * 1000 < MAX_AGE_MS) {
|
|
40
|
+
_trace("auto-update", "allow", { reason: "recently-checked" });
|
|
22
41
|
process.exit(0);
|
|
23
42
|
}
|
|
24
43
|
}
|
|
25
44
|
|
|
26
45
|
// Already updating
|
|
27
46
|
if (fs.existsSync(LOCK_FILE)) {
|
|
47
|
+
_trace("auto-update", "allow", { reason: "already-updating" });
|
|
28
48
|
process.exit(0);
|
|
29
49
|
}
|
|
30
50
|
|
|
@@ -36,9 +56,13 @@ try {
|
|
|
36
56
|
try {
|
|
37
57
|
cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
|
|
38
58
|
} catch {
|
|
59
|
+
_trace("auto-update", "allow", { reason: "config-unreadable" });
|
|
60
|
+
process.exit(0);
|
|
61
|
+
}
|
|
62
|
+
if (!cfg.code || !cfg.version) {
|
|
63
|
+
_trace("auto-update", "allow", { reason: "config-incomplete" });
|
|
39
64
|
process.exit(0);
|
|
40
65
|
}
|
|
41
|
-
if (!cfg.code || !cfg.version) process.exit(0);
|
|
42
66
|
|
|
43
67
|
// Fork the check-and-update into a detached background process so the hook
|
|
44
68
|
// returns immediately and Claude Code is never blocked.
|
|
@@ -58,7 +82,7 @@ try {
|
|
|
58
82
|
shell: process.platform === "win32",
|
|
59
83
|
});
|
|
60
84
|
const latest = ((r.stdout || "").trim());
|
|
61
|
-
if (!latest) { fs.unlinkSync(LOCK_FILE);
|
|
85
|
+
if (!latest) { fs.unlinkSync(LOCK_FILE); process.exit(0); }
|
|
62
86
|
const cmp = (a, b) => {
|
|
63
87
|
const pa = a.split(".").map(Number), pb = b.split(".").map(Number);
|
|
64
88
|
for (let i = 0; i < 3; i++) {
|
|
@@ -89,4 +113,5 @@ try {
|
|
|
89
113
|
// Silent — never block the tool call
|
|
90
114
|
}
|
|
91
115
|
|
|
116
|
+
_trace("auto-update", "allow", { reason: "check-spawned" });
|
|
92
117
|
process.exit(0);
|
package/hooks/block-env-edit.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
const fs = require("fs");
|
|
8
8
|
|
|
9
|
+
const _traceStart = Date.now();
|
|
10
|
+
|
|
9
11
|
function readInput() {
|
|
10
12
|
try {
|
|
11
13
|
const raw = fs.readFileSync(0, "utf8");
|
|
@@ -22,9 +24,29 @@ const file = (input.tool_input && (input.tool_input.file_path || input.tool_inpu
|
|
|
22
24
|
// Normalize separators so Windows paths (C:\project\.env.local) also match.
|
|
23
25
|
const normalized = String(file).replace(/\\/g, "/");
|
|
24
26
|
|
|
27
|
+
function _trace(hookName, result, extra) {
|
|
28
|
+
try {
|
|
29
|
+
const os = require("os");
|
|
30
|
+
const path = require("path");
|
|
31
|
+
const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
|
|
32
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
33
|
+
const entry = {
|
|
34
|
+
hook: hookName,
|
|
35
|
+
result,
|
|
36
|
+
timestamp: new Date().toISOString(),
|
|
37
|
+
duration_ms: Date.now() - _traceStart,
|
|
38
|
+
...extra,
|
|
39
|
+
};
|
|
40
|
+
const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
41
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
42
|
+
} catch {}
|
|
43
|
+
}
|
|
44
|
+
|
|
25
45
|
if (/\.env(\.|$)/.test(normalized)) {
|
|
26
46
|
console.log("BLOCKED: Cannot edit environment files. Ask Fawzi to update secrets.");
|
|
47
|
+
_trace("block-env-edit", "block", { file: normalized });
|
|
27
48
|
process.exit(2);
|
|
28
49
|
}
|
|
29
50
|
|
|
51
|
+
_trace("block-env-edit", "allow");
|
|
30
52
|
process.exit(0);
|
package/hooks/branch-guard.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/hooks/migration-guard.js
CHANGED
|
@@ -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);
|
package/hooks/pre-compact.js
CHANGED
|
@@ -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);
|
package/hooks/pre-deploy-gate.js
CHANGED
|
@@ -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);
|
package/hooks/session-start.js
CHANGED
|
@@ -12,6 +12,8 @@ const path = require("path");
|
|
|
12
12
|
const os = require("os");
|
|
13
13
|
const { spawnSync } = require("child_process");
|
|
14
14
|
|
|
15
|
+
const _traceStart = Date.now();
|
|
16
|
+
|
|
15
17
|
const HOME = os.homedir();
|
|
16
18
|
const UI = path.join(HOME, ".claude", "bin", "qualia-ui.js");
|
|
17
19
|
const STATE_FILE = path.join(".planning", "STATE.md");
|
|
@@ -81,4 +83,21 @@ try {
|
|
|
81
83
|
// Deliberately silent — hook must never fail
|
|
82
84
|
}
|
|
83
85
|
|
|
86
|
+
function _trace(hookName, result, extra) {
|
|
87
|
+
try {
|
|
88
|
+
const traceDir = path.join(os.homedir(), ".claude", ".qualia-traces");
|
|
89
|
+
if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
|
|
90
|
+
const entry = {
|
|
91
|
+
hook: hookName,
|
|
92
|
+
result,
|
|
93
|
+
timestamp: new Date().toISOString(),
|
|
94
|
+
duration_ms: Date.now() - _traceStart,
|
|
95
|
+
...extra,
|
|
96
|
+
};
|
|
97
|
+
const file = path.join(traceDir, `${new Date().toISOString().split("T")[0]}.jsonl`);
|
|
98
|
+
fs.appendFileSync(file, JSON.stringify(entry) + "\n");
|
|
99
|
+
} catch {}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
_trace("session-start", "allow");
|
|
84
103
|
process.exit(0);
|
package/package.json
CHANGED
package/skills/qualia/SKILL.md
CHANGED
|
@@ -47,6 +47,7 @@ Use the state.js JSON output plus gathered context:
|
|
|
47
47
|
| `handed-off` | status == "handed_off" | → `/qualia-report` then done |
|
|
48
48
|
| `blocked` | STATE.md lists blockers or same error 3+ times | → Analyze, suggest `/qualia-debug` |
|
|
49
49
|
| `bug-loop` | Same files edited 3+ times, user frustrated | → Different approach, `/qualia-debug` |
|
|
50
|
+
| `need-tests` | User mentions "tests", "coverage", "test this" | → `/qualia-test` |
|
|
50
51
|
|
|
51
52
|
**Employee escalation:** If role is EMPLOYEE and situation is `gap-limit` or `bug-loop`, suggest: "Want to flag this for Fawzi?"
|
|
52
53
|
|
|
@@ -21,6 +21,24 @@ cat .planning/phase-{N}-plan.md
|
|
|
21
21
|
|
|
22
22
|
Parse: tasks, waves, file references.
|
|
23
23
|
|
|
24
|
+
### 1b. Create Recovery Point
|
|
25
|
+
|
|
26
|
+
Before executing any tasks, tag current HEAD for rollback:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git tag -f "pre-build-phase-{N}" HEAD 2>/dev/null
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
node ~/.claude/bin/qualia-ui.js info "Recovery point: pre-build-phase-{N}"
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If a wave fails and the user needs to roll back:
|
|
37
|
+
```bash
|
|
38
|
+
git reset --hard pre-build-phase-{N}
|
|
39
|
+
node ~/.claude/bin/state.js transition --to planned --force
|
|
40
|
+
```
|
|
41
|
+
|
|
24
42
|
### 2. Execute Waves
|
|
25
43
|
|
|
26
44
|
```bash
|
|
@@ -46,17 +46,40 @@ What did you learn?
|
|
|
46
46
|
3. Client preference — client-specific requirement
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
### 2.
|
|
49
|
+
### 2. Check for Duplicates
|
|
50
|
+
|
|
51
|
+
Before saving, check if a similar entry already exists:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Search for the title (case-insensitive substring match)
|
|
55
|
+
grep -i "{title keywords}" ~/.claude/knowledge/{type}.md 2>/dev/null
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
If a near-match exists (title is similar to an existing entry):
|
|
59
|
+
- Show the existing entry to the user
|
|
60
|
+
- Ask: "A similar entry exists. Update it, create a new one, or skip?"
|
|
61
|
+
- If update: replace the existing entry. If new: append. If skip: done.
|
|
62
|
+
|
|
63
|
+
### 3. Format Entry
|
|
64
|
+
|
|
65
|
+
Each entry gets a unique ID and ISO timestamp for dedup and ordering:
|
|
50
66
|
|
|
51
67
|
```markdown
|
|
52
|
-
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
### {Title}
|
|
72
|
+
**ID:** {random 8-char hex, e.g. a3f7c1e9}
|
|
73
|
+
**Date:** {ISO 8601, e.g. 2026-04-11}
|
|
53
74
|
**Project:** {current project name or "general"}
|
|
54
75
|
**Context:** {brief context — what you were building when you learned this}
|
|
55
76
|
|
|
56
77
|
{The learning — be specific enough that future-you understands without context}
|
|
57
78
|
```
|
|
58
79
|
|
|
59
|
-
###
|
|
80
|
+
### 4. Append to Knowledge File
|
|
81
|
+
|
|
82
|
+
Append-only — never overwrite the file, always add at the end:
|
|
60
83
|
|
|
61
84
|
```bash
|
|
62
85
|
# Append to the right file
|
|
@@ -67,7 +90,7 @@ echo "{formatted entry}" >> ~/.claude/knowledge/{type}.md
|
|
|
67
90
|
- Fix → `~/.claude/knowledge/common-fixes.md`
|
|
68
91
|
- Client pref → `~/.claude/knowledge/client-prefs.md`
|
|
69
92
|
|
|
70
|
-
###
|
|
93
|
+
### 5. Confirm
|
|
71
94
|
|
|
72
95
|
```
|
|
73
96
|
⬢ Saved to {file}
|