qualia-framework-v2 2.9.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +151 -2
- package/bin/install.js +48 -43
- package/bin/qualia-ui.js +27 -27
- package/bin/state.js +106 -6
- package/bin/statusline.js +53 -3
- 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 +24 -1
- package/hooks/pre-compact.js +20 -0
- package/hooks/pre-deploy-gate.js +41 -2
- 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-design/SKILL.md +1 -1
- package/skills/qualia-learn/SKILL.md +28 -5
- package/skills/qualia-new/SKILL.md +2 -2
- 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/install.js
CHANGED
|
@@ -13,8 +13,11 @@ const YELLOW = "\x1b[38;2;234;179;8m";
|
|
|
13
13
|
const RED = "\x1b[38;2;239;68;68m";
|
|
14
14
|
const RESET = "\x1b[0m";
|
|
15
15
|
|
|
16
|
+
const CLAUDE_DIR = path.join(require("os").homedir(), ".claude");
|
|
17
|
+
const FRAMEWORK_DIR = path.resolve(__dirname, "..");
|
|
18
|
+
|
|
16
19
|
// ─── Team codes ──────────────────────────────────────────
|
|
17
|
-
const
|
|
20
|
+
const DEFAULT_TEAM = {
|
|
18
21
|
"QS-FAWZI-01": {
|
|
19
22
|
name: "Fawzi Goussous",
|
|
20
23
|
role: "OWNER",
|
|
@@ -42,8 +45,21 @@ const TEAM = {
|
|
|
42
45
|
},
|
|
43
46
|
};
|
|
44
47
|
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
// Load team from external file, fall back to embedded defaults.
|
|
49
|
+
function loadTeam() {
|
|
50
|
+
const teamFile = path.join(CLAUDE_DIR, ".qualia-team.json");
|
|
51
|
+
try {
|
|
52
|
+
if (fs.existsSync(teamFile)) {
|
|
53
|
+
const external = JSON.parse(fs.readFileSync(teamFile, "utf8"));
|
|
54
|
+
if (external && typeof external === "object" && Object.keys(external).length > 0) {
|
|
55
|
+
return external;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {}
|
|
59
|
+
return DEFAULT_TEAM;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const TEAM = loadTeam();
|
|
47
63
|
|
|
48
64
|
let installed = 0;
|
|
49
65
|
let errors = 0;
|
|
@@ -70,7 +86,7 @@ function askCode() {
|
|
|
70
86
|
return new Promise((resolve) => {
|
|
71
87
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
72
88
|
console.log("");
|
|
73
|
-
console.log(`${TEAL}
|
|
89
|
+
console.log(`${TEAL} ⬢ Qualia Framework v2${RESET}`);
|
|
74
90
|
console.log(`${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
|
|
75
91
|
console.log("");
|
|
76
92
|
rl.question(` ${WHITE}Enter install code:${RESET} `, (answer) => {
|
|
@@ -185,16 +201,6 @@ async function main() {
|
|
|
185
201
|
}
|
|
186
202
|
}
|
|
187
203
|
|
|
188
|
-
// ─── Status line ───────────────────────────────────────
|
|
189
|
-
log(`${WHITE}Status line${RESET}`);
|
|
190
|
-
try {
|
|
191
|
-
const slDest = path.join(CLAUDE_DIR, "bin", "statusline.js");
|
|
192
|
-
copy(path.join(FRAMEWORK_DIR, "bin", "statusline.js"), slDest);
|
|
193
|
-
ok("statusline.js");
|
|
194
|
-
} catch (e) {
|
|
195
|
-
warn(`statusline.js — ${e.message}`);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
204
|
// ─── Templates ─────────────────────────────────────────
|
|
199
205
|
log(`${WHITE}Templates${RESET}`);
|
|
200
206
|
const tmplDir = path.join(FRAMEWORK_DIR, "templates");
|
|
@@ -363,6 +369,11 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
363
369
|
role: member.role,
|
|
364
370
|
version: require("../package.json").version,
|
|
365
371
|
installed_at: new Date().toISOString().split("T")[0],
|
|
372
|
+
erp: {
|
|
373
|
+
enabled: true,
|
|
374
|
+
url: "https://portal.qualiasolutions.net",
|
|
375
|
+
api_key_file: ".erp-api-key",
|
|
376
|
+
},
|
|
366
377
|
};
|
|
367
378
|
fs.writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
|
|
368
379
|
|
|
@@ -425,16 +436,16 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
425
436
|
settings.spinnerTipsOverride = {
|
|
426
437
|
excludeDefault: true,
|
|
427
438
|
tips: [
|
|
428
|
-
"
|
|
429
|
-
"
|
|
430
|
-
"
|
|
431
|
-
"
|
|
432
|
-
"
|
|
433
|
-
"
|
|
434
|
-
"
|
|
435
|
-
"
|
|
436
|
-
"
|
|
437
|
-
"
|
|
439
|
+
"⬢ Lost? Type /qualia for the next step",
|
|
440
|
+
"⬢ Small fix? Use /qualia-quick to skip planning",
|
|
441
|
+
"⬢ End of day? /qualia-report before you clock out",
|
|
442
|
+
"⬢ Context isolation: every task gets a fresh AI brain",
|
|
443
|
+
"⬢ The verifier doesn't trust claims — it greps the code",
|
|
444
|
+
"⬢ Plans are prompts — the plan IS what the builder reads",
|
|
445
|
+
"⬢ Feature branches only — never push to main",
|
|
446
|
+
"⬢ Read before write — no exceptions",
|
|
447
|
+
"⬢ MVP first — build what's asked, nothing extra",
|
|
448
|
+
"⬢ tracking.json syncs to ERP on every push",
|
|
438
449
|
],
|
|
439
450
|
};
|
|
440
451
|
|
|
@@ -469,22 +480,22 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
469
480
|
type: "command",
|
|
470
481
|
if: "Bash(git push*)",
|
|
471
482
|
command: nodeCmd("branch-guard.js"),
|
|
472
|
-
timeout:
|
|
473
|
-
statusMessage: "
|
|
483
|
+
timeout: 5,
|
|
484
|
+
statusMessage: "⬢ Checking branch permissions...",
|
|
474
485
|
},
|
|
475
486
|
{
|
|
476
487
|
type: "command",
|
|
477
488
|
if: "Bash(git push*)",
|
|
478
489
|
command: nodeCmd("pre-push.js"),
|
|
479
490
|
timeout: 15,
|
|
480
|
-
statusMessage: "
|
|
491
|
+
statusMessage: "⬢ Syncing tracking...",
|
|
481
492
|
},
|
|
482
493
|
{
|
|
483
494
|
type: "command",
|
|
484
495
|
if: "Bash(vercel --prod*)",
|
|
485
496
|
command: nodeCmd("pre-deploy-gate.js"),
|
|
486
497
|
timeout: 180,
|
|
487
|
-
statusMessage: "
|
|
498
|
+
statusMessage: "⬢ Running quality gates...",
|
|
488
499
|
},
|
|
489
500
|
],
|
|
490
501
|
},
|
|
@@ -493,17 +504,16 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
493
504
|
hooks: [
|
|
494
505
|
{
|
|
495
506
|
type: "command",
|
|
496
|
-
if: "Edit(*.env*)|Write(*.env*)",
|
|
497
507
|
command: nodeCmd("block-env-edit.js"),
|
|
498
508
|
timeout: 5,
|
|
499
|
-
statusMessage: "
|
|
509
|
+
statusMessage: "⬢ Checking file permissions...",
|
|
500
510
|
},
|
|
501
511
|
{
|
|
502
512
|
type: "command",
|
|
503
513
|
if: "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)",
|
|
504
514
|
command: nodeCmd("migration-guard.js"),
|
|
505
515
|
timeout: 10,
|
|
506
|
-
statusMessage: "
|
|
516
|
+
statusMessage: "⬢ Checking migration safety...",
|
|
507
517
|
},
|
|
508
518
|
],
|
|
509
519
|
},
|
|
@@ -516,39 +526,34 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
516
526
|
type: "command",
|
|
517
527
|
command: nodeCmd("pre-compact.js"),
|
|
518
528
|
timeout: 15,
|
|
519
|
-
statusMessage: "
|
|
529
|
+
statusMessage: "⬢ Saving state...",
|
|
520
530
|
},
|
|
521
531
|
],
|
|
522
532
|
},
|
|
523
533
|
],
|
|
524
534
|
};
|
|
525
535
|
|
|
526
|
-
// Permissions
|
|
536
|
+
// Permissions — no restrictions on env files or branches.
|
|
537
|
+
// Everyone can read/write .env, push to main.
|
|
527
538
|
if (!settings.permissions) settings.permissions = {};
|
|
528
539
|
if (!settings.permissions.allow) settings.permissions.allow = [];
|
|
529
|
-
if (!settings.permissions.deny)
|
|
530
|
-
settings.permissions.deny = [
|
|
531
|
-
"Read(./.env)",
|
|
532
|
-
"Read(./.env.*)",
|
|
533
|
-
"Read(./secrets/**)",
|
|
534
|
-
];
|
|
535
|
-
}
|
|
540
|
+
if (!settings.permissions.deny) settings.permissions.deny = [];
|
|
536
541
|
|
|
537
542
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
538
543
|
|
|
539
|
-
ok("Hooks: session-start, auto-update, branch-guard, pre-push, env-
|
|
544
|
+
ok("Hooks: session-start, auto-update, branch-guard, pre-push, block-env-edit, migration-guard, deploy-gate, pre-compact");
|
|
540
545
|
ok("Status line + spinner configured");
|
|
541
546
|
ok("Environment variables + permissions");
|
|
542
547
|
|
|
543
548
|
// ─── Summary ───────────────────────────────────────────
|
|
544
549
|
console.log("");
|
|
545
|
-
console.log(`${TEAL}
|
|
550
|
+
console.log(`${TEAL} ⬢ Installed ✓${RESET}`);
|
|
546
551
|
console.log(`${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
|
|
547
552
|
console.log(` ${WHITE}${member.name}${RESET} ${DIM}(${member.role})${RESET}`);
|
|
548
553
|
console.log(` Skills: ${WHITE}${skills.length}${RESET}`);
|
|
549
554
|
const agentCount = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')).length;
|
|
550
555
|
console.log(` Agents: ${WHITE}${agentCount}${RESET} ${DIM}(planner, builder, verifier, qa-browser)${RESET}`);
|
|
551
|
-
console.log(` Hooks: ${WHITE}8${RESET} ${DIM}(session-start, auto-update, branch-guard, pre-push, env-
|
|
556
|
+
console.log(` Hooks: ${WHITE}8${RESET} ${DIM}(session-start, auto-update, branch-guard, pre-push, block-env-edit, migration-guard, deploy-gate, pre-compact)${RESET}`);
|
|
552
557
|
console.log(` Rules: ${WHITE}${fs.readdirSync(rulesDir).length}${RESET} ${DIM}(security, frontend, design-reference, deployment)${RESET}`);
|
|
553
558
|
console.log(` Scripts: ${WHITE}3${RESET} ${DIM}(state.js, qualia-ui.js, statusline.js)${RESET}`);
|
|
554
559
|
console.log(` Knowledge: ${WHITE}3${RESET} ${DIM}(patterns, fixes, client prefs)${RESET}`);
|
package/bin/qualia-ui.js
CHANGED
|
@@ -40,25 +40,25 @@ const RULE_DIM = `${DIM2}${RULE}${RESET}`;
|
|
|
40
40
|
|
|
41
41
|
// ─── Action Labels ───────────────────────────────────────
|
|
42
42
|
const ACTIONS = {
|
|
43
|
-
router: { label: "SMART ROUTER", glyph: "
|
|
44
|
-
new: { label: "NEW PROJECT", glyph: "
|
|
45
|
-
plan: { label: "PLANNING", glyph: "
|
|
46
|
-
build: { label: "BUILDING", glyph: "
|
|
47
|
-
verify: { label: "VERIFYING", glyph: "
|
|
48
|
-
polish: { label: "POLISHING", glyph: "
|
|
49
|
-
ship: { label: "SHIPPING", glyph: "
|
|
50
|
-
handoff: { label: "HANDING OFF", glyph: "
|
|
51
|
-
report: { label: "SESSION REPORT", glyph: "
|
|
52
|
-
debug: { label: "DEBUGGING", glyph: "
|
|
53
|
-
learn: { label: "LEARNING", glyph: "
|
|
54
|
-
pause: { label: "PAUSING", glyph: "
|
|
55
|
-
resume: { label: "RESUMING", glyph: "
|
|
56
|
-
review: { label: "REVIEW", glyph: "
|
|
57
|
-
design: { label: "DESIGN PASS", glyph: "
|
|
58
|
-
quick: { label: "QUICK FIX", glyph: "
|
|
59
|
-
task: { label: "TASK", glyph: "
|
|
60
|
-
"skill-new": { label: "NEW SKILL", glyph: "
|
|
61
|
-
gaps: { label: "GAP CLOSURE", glyph: "
|
|
43
|
+
router: { label: "SMART ROUTER", glyph: "⬢" },
|
|
44
|
+
new: { label: "NEW PROJECT", glyph: "✦" },
|
|
45
|
+
plan: { label: "PLANNING", glyph: "▣" },
|
|
46
|
+
build: { label: "BUILDING", glyph: "⚙" },
|
|
47
|
+
verify: { label: "VERIFYING", glyph: "◎" },
|
|
48
|
+
polish: { label: "POLISHING", glyph: "✧" },
|
|
49
|
+
ship: { label: "SHIPPING", glyph: "△" },
|
|
50
|
+
handoff: { label: "HANDING OFF", glyph: "⇢" },
|
|
51
|
+
report: { label: "SESSION REPORT", glyph: "▤" },
|
|
52
|
+
debug: { label: "DEBUGGING", glyph: "⊘" },
|
|
53
|
+
learn: { label: "LEARNING", glyph: "⊙" },
|
|
54
|
+
pause: { label: "PAUSING", glyph: "⏸" },
|
|
55
|
+
resume: { label: "RESUMING", glyph: "▶" },
|
|
56
|
+
review: { label: "REVIEW", glyph: "⊛" },
|
|
57
|
+
design: { label: "DESIGN PASS", glyph: "◈" },
|
|
58
|
+
quick: { label: "QUICK FIX", glyph: "⚡" },
|
|
59
|
+
task: { label: "TASK", glyph: "▪" },
|
|
60
|
+
"skill-new": { label: "NEW SKILL", glyph: "✦" },
|
|
61
|
+
gaps: { label: "GAP CLOSURE", glyph: "⟐" },
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
// ─── State Reading ───────────────────────────────────────
|
|
@@ -126,7 +126,7 @@ function pad(str, width) {
|
|
|
126
126
|
|
|
127
127
|
// ─── Commands ────────────────────────────────────────────
|
|
128
128
|
function cmdBanner(action, phase, subtitle) {
|
|
129
|
-
const spec = ACTIONS[action] || { label: (action || "qualia").toUpperCase(), glyph: "
|
|
129
|
+
const spec = ACTIONS[action] || { label: (action || "qualia").toUpperCase(), glyph: "⬢" };
|
|
130
130
|
const state = readState();
|
|
131
131
|
const config = readConfig();
|
|
132
132
|
const project = projectName();
|
|
@@ -136,7 +136,7 @@ function cmdBanner(action, phase, subtitle) {
|
|
|
136
136
|
: spec.label;
|
|
137
137
|
|
|
138
138
|
console.log("");
|
|
139
|
-
console.log(` ${TEAL}${BOLD}${spec.glyph}${RESET} ${WHITE}${BOLD}QUALIA${RESET} ${DIM}
|
|
139
|
+
console.log(` ${TEAL}${BOLD}${spec.glyph}${RESET} ${WHITE}${BOLD}QUALIA${RESET} ${DIM}▸${RESET} ${WHITE}${title}${RESET}`);
|
|
140
140
|
console.log(` ${RULE_DIM}`);
|
|
141
141
|
|
|
142
142
|
// Context panel
|
|
@@ -159,7 +159,7 @@ function cmdBanner(action, phase, subtitle) {
|
|
|
159
159
|
const bar = progressBar(state.phase, state.total_phases);
|
|
160
160
|
if (bar) console.log(` ${pad(DIM + "Progress" + RESET, 20)}${bar}`);
|
|
161
161
|
if (state.gap_cycles > 0) {
|
|
162
|
-
console.log(` ${pad(DIM + "Gap cycles" + RESET, 20)}${YELLOW}${state.gap_cycles}
|
|
162
|
+
console.log(` ${pad(DIM + "Gap cycles" + RESET, 20)}${YELLOW}${state.gap_cycles}/${state.gap_cycle_limit || 2}${RESET}`);
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
@@ -218,7 +218,7 @@ function cmdInfo(msg) {
|
|
|
218
218
|
function cmdSpawn(agent, desc) {
|
|
219
219
|
const name = agent || "agent";
|
|
220
220
|
const d = desc ? ` ${DIM}— ${desc}${RESET}` : "";
|
|
221
|
-
console.log(` ${TEAL}
|
|
221
|
+
console.log(` ${TEAL}⬡${RESET} ${WHITE}Spawning${RESET} ${TEAL}${name}${RESET}${d}`);
|
|
222
222
|
}
|
|
223
223
|
|
|
224
224
|
function cmdWave(num, total, taskCount) {
|
|
@@ -226,7 +226,7 @@ function cmdWave(num, total, taskCount) {
|
|
|
226
226
|
const n = parseInt(num) || 0;
|
|
227
227
|
const t = parseInt(total) || 0;
|
|
228
228
|
const c = parseInt(taskCount) || 0;
|
|
229
|
-
console.log(` ${TEAL}
|
|
229
|
+
console.log(` ${TEAL}»${RESET} ${WHITE}${BOLD}Wave ${n}/${t}${RESET} ${DIM}(${c} ${c === 1 ? "task" : "tasks"}, parallel)${RESET}`);
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
function cmdTask(num, title) {
|
|
@@ -241,16 +241,16 @@ function cmdDone(num, title, commit) {
|
|
|
241
241
|
function cmdNext(cmd) {
|
|
242
242
|
if (!cmd) return;
|
|
243
243
|
console.log("");
|
|
244
|
-
console.log(` ${TEAL}
|
|
244
|
+
console.log(` ${TEAL}⟶${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${cmd}${RESET}`);
|
|
245
245
|
console.log("");
|
|
246
246
|
}
|
|
247
247
|
|
|
248
248
|
function cmdEnd(status, nextCmd) {
|
|
249
249
|
console.log("");
|
|
250
|
-
console.log(` ${TEAL}${BOLD}
|
|
250
|
+
console.log(` ${TEAL}${BOLD}⬢${RESET} ${WHITE}${BOLD}${status || "DONE"}${RESET}`);
|
|
251
251
|
console.log(` ${RULE_DIM}`);
|
|
252
252
|
if (nextCmd) {
|
|
253
|
-
console.log(` ${TEAL}
|
|
253
|
+
console.log(` ${TEAL}⟶${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${nextCmd}${RESET}`);
|
|
254
254
|
}
|
|
255
255
|
console.log("");
|
|
256
256
|
}
|
package/bin/state.js
CHANGED
|
@@ -186,6 +186,25 @@ const VALID_FROM = {
|
|
|
186
186
|
done: ["handed_off"],
|
|
187
187
|
};
|
|
188
188
|
|
|
189
|
+
// ─── Configurable Gap Cycle Limit ────────────────────────
|
|
190
|
+
function getGapCycleLimit() {
|
|
191
|
+
// Priority: tracking.json.gap_cycle_limit > PROJECT.md > default (2)
|
|
192
|
+
try {
|
|
193
|
+
const t = readTracking();
|
|
194
|
+
if (t && typeof t.gap_cycle_limit === "number" && t.gap_cycle_limit > 0) {
|
|
195
|
+
return t.gap_cycle_limit;
|
|
196
|
+
}
|
|
197
|
+
} catch {}
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
const projectMd = fs.readFileSync(path.join(PLANNING, "PROJECT.md"), "utf8");
|
|
201
|
+
const match = projectMd.match(/^gap_cycle_limit:\s*(\d+)/m);
|
|
202
|
+
if (match) return parseInt(match[1]);
|
|
203
|
+
} catch {}
|
|
204
|
+
|
|
205
|
+
return 2; // default
|
|
206
|
+
}
|
|
207
|
+
|
|
189
208
|
function checkPreconditions(current, target, opts) {
|
|
190
209
|
const phase = parseInt(opts.phase) || current.phase;
|
|
191
210
|
|
|
@@ -207,6 +226,14 @@ function checkPreconditions(current, target, opts) {
|
|
|
207
226
|
const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
|
|
208
227
|
if (!fs.existsSync(planFile))
|
|
209
228
|
return fail("MISSING_FILE", `Plan file not found: ${planFile}`);
|
|
229
|
+
// Validate plan content (not just existence)
|
|
230
|
+
const planContent = fs.readFileSync(planFile, "utf8");
|
|
231
|
+
const taskHeaders = planContent.match(/^## Task \d+/gm);
|
|
232
|
+
if (!taskHeaders || taskHeaders.length === 0)
|
|
233
|
+
return fail("INVALID_PLAN", "Plan file has no task headers (expected '## Task N')");
|
|
234
|
+
const doneWhenCount = (planContent.match(/\*\*Done when:\*\*/g) || []).length;
|
|
235
|
+
if (doneWhenCount < taskHeaders.length)
|
|
236
|
+
return fail("INVALID_PLAN", `${taskHeaders.length} tasks but only ${doneWhenCount} 'Done when:' entries`);
|
|
210
237
|
}
|
|
211
238
|
|
|
212
239
|
if (target === "verified") {
|
|
@@ -228,14 +255,15 @@ function checkPreconditions(current, target, opts) {
|
|
|
228
255
|
return fail("MISSING_FILE", `Handoff file not found: ${hFile}`);
|
|
229
256
|
}
|
|
230
257
|
|
|
231
|
-
// Gap-closure circuit breaker
|
|
258
|
+
// Gap-closure circuit breaker (configurable limit)
|
|
232
259
|
if (target === "planned" && current.status === "verified") {
|
|
233
260
|
const t = readTracking() || {};
|
|
234
261
|
const cycles = (t.gap_cycles || {})[String(phase)] || 0;
|
|
235
|
-
|
|
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/bin/statusline.js
CHANGED
|
@@ -11,6 +11,7 @@ const fs = require("fs");
|
|
|
11
11
|
const os = require("os");
|
|
12
12
|
const path = require("path");
|
|
13
13
|
const { spawnSync } = require("child_process");
|
|
14
|
+
const HOME = os.homedir();
|
|
14
15
|
|
|
15
16
|
// ─── Colors (matches bin/qualia-ui.js palette) ───────────
|
|
16
17
|
const TEAL = "\x1b[38;2;0;206;209m";
|
|
@@ -151,6 +152,47 @@ try {
|
|
|
151
152
|
}
|
|
152
153
|
} catch {}
|
|
153
154
|
|
|
155
|
+
// ─── Memory count ────────────────────────────────────────
|
|
156
|
+
let MEMORY_COUNT = 0;
|
|
157
|
+
try {
|
|
158
|
+
const dirKey = DIR.replace(/\//g, "-");
|
|
159
|
+
const memDir = path.join(HOME, ".claude", "projects", dirKey, "memory");
|
|
160
|
+
if (fs.existsSync(memDir)) {
|
|
161
|
+
const files = fs.readdirSync(memDir).filter(f => f.endsWith(".md") && f !== "MEMORY.md");
|
|
162
|
+
MEMORY_COUNT = files.length;
|
|
163
|
+
}
|
|
164
|
+
} catch {}
|
|
165
|
+
|
|
166
|
+
// ─── Hooks count ─────────────────────────────────────────
|
|
167
|
+
let HOOKS_COUNT = 0;
|
|
168
|
+
try {
|
|
169
|
+
const settingsPath = path.join(HOME, ".claude", "settings.json");
|
|
170
|
+
if (fs.existsSync(settingsPath)) {
|
|
171
|
+
const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
|
|
172
|
+
if (settings.hooks) {
|
|
173
|
+
for (const event of Object.values(settings.hooks)) {
|
|
174
|
+
if (Array.isArray(event)) {
|
|
175
|
+
for (const matcher of event) {
|
|
176
|
+
if (matcher.hooks && Array.isArray(matcher.hooks)) {
|
|
177
|
+
HOOKS_COUNT += matcher.hooks.length;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
|
|
186
|
+
// ─── Skills count ────────────────────────────────────────
|
|
187
|
+
let SKILLS_COUNT = 0;
|
|
188
|
+
try {
|
|
189
|
+
const skillsDir = path.join(HOME, ".claude", "skills");
|
|
190
|
+
if (fs.existsSync(skillsDir)) {
|
|
191
|
+
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
192
|
+
SKILLS_COUNT = entries.filter(e => e.isDirectory() || e.name.endsWith(".md")).length;
|
|
193
|
+
}
|
|
194
|
+
} catch {}
|
|
195
|
+
|
|
154
196
|
// ─── Duration ────────────────────────────────────────────
|
|
155
197
|
let DUR = "0s";
|
|
156
198
|
try {
|
|
@@ -167,11 +209,11 @@ try {
|
|
|
167
209
|
COST_FMT = `$${COST.toFixed(2)}`;
|
|
168
210
|
} catch {}
|
|
169
211
|
|
|
170
|
-
// ─── Line 1: Project + Git + Agent + Worktree + Phase
|
|
212
|
+
// ─── Line 1: Project + Git + Agent + Worktree + Phase + Memory + Hooks ──
|
|
171
213
|
let LINE1 = "";
|
|
172
214
|
try {
|
|
173
215
|
const dirBase = path.basename(DIR) || DIR;
|
|
174
|
-
LINE1 = `${TEAL}
|
|
216
|
+
LINE1 = `${TEAL}⬢${RESET} ${WHITE}${dirBase}${RESET}`;
|
|
175
217
|
if (BRANCH) {
|
|
176
218
|
if (CHANGES > 0) {
|
|
177
219
|
LINE1 += ` ${DIM}on${RESET} ${TEAL_GLOW}${BRANCH}${RESET} ${YELLOW}~${CHANGES}${RESET}`;
|
|
@@ -182,8 +224,16 @@ try {
|
|
|
182
224
|
if (AGENT) LINE1 += ` ${DIM}│${RESET} ${TEAL}⚡${AGENT}${RESET}`;
|
|
183
225
|
if (WORKTREE) LINE1 += ` ${DIM}│${RESET} ${TEAL_DIM}⎇ ${WORKTREE}${RESET}`;
|
|
184
226
|
if (PHASE_INFO) LINE1 += ` ${DIM}│${RESET} ${PHASE_INFO}`;
|
|
227
|
+
// Memory, hooks, skills — context indicators
|
|
228
|
+
const contextParts = [];
|
|
229
|
+
if (MEMORY_COUNT > 0) contextParts.push(`${TEAL}⊙${RESET}${DIM}${MEMORY_COUNT}${RESET}`);
|
|
230
|
+
if (HOOKS_COUNT > 0) contextParts.push(`${TEAL_GLOW}⚙${RESET}${DIM}${HOOKS_COUNT}${RESET}`);
|
|
231
|
+
if (SKILLS_COUNT > 0) contextParts.push(`${TEAL_DIM}✦${RESET}${DIM}${SKILLS_COUNT}${RESET}`);
|
|
232
|
+
if (contextParts.length > 0) {
|
|
233
|
+
LINE1 += ` ${DIM}│${RESET} ${contextParts.join(` ${DIM}·${RESET} `)}`;
|
|
234
|
+
}
|
|
185
235
|
} catch {
|
|
186
|
-
LINE1 = `${TEAL}
|
|
236
|
+
LINE1 = `${TEAL}⬢${RESET} ${WHITE}qualia${RESET}`;
|
|
187
237
|
}
|
|
188
238
|
|
|
189
239
|
// ─── Line 2: Context bar + Cost + Duration + Model ───────
|
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);
|