qualia-framework 3.2.0 → 3.2.1
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 +3 -4
- package/README.md +10 -5
- package/agents/planner.md +52 -0
- package/agents/verifier.md +180 -32
- package/bin/cli.js +403 -9
- package/bin/install.js +118 -65
- package/bin/qualia-ui.js +11 -11
- package/bin/state.js +200 -6
- package/bin/statusline.js +4 -4
- package/docs/erp-contract.md +161 -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 +16 -43
- package/package.json +5 -4
- package/rules/infrastructure.md +87 -0
- package/skills/qualia/SKILL.md +1 -0
- package/skills/qualia-build/SKILL.md +18 -0
- package/skills/qualia-design/SKILL.md +14 -8
- package/skills/qualia-help/SKILL.md +60 -0
- package/skills/qualia-learn/SKILL.md +27 -4
- package/skills/qualia-polish/SKILL.md +167 -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/DESIGN.md +440 -102
- package/templates/help.html +476 -0
- package/templates/plan.md +14 -0
- package/tests/bin.test.sh +20 -6
- package/tests/hooks.test.sh +76 -7
- package/tests/runner.js +1915 -0
- 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",
|
|
@@ -23,27 +26,40 @@ const TEAM = {
|
|
|
23
26
|
"QS-HASAN-02": {
|
|
24
27
|
name: "Hasan",
|
|
25
28
|
role: "EMPLOYEE",
|
|
26
|
-
description: "Developer. Feature branches only. Cannot push to main
|
|
29
|
+
description: "Developer. Feature branches only. Cannot push to main.",
|
|
27
30
|
},
|
|
28
31
|
"QS-MOAYAD-03": {
|
|
29
32
|
name: "Moayad",
|
|
30
33
|
role: "EMPLOYEE",
|
|
31
|
-
description: "Developer. Feature branches only. Cannot push to main
|
|
34
|
+
description: "Developer. Feature branches only. Cannot push to main.",
|
|
32
35
|
},
|
|
33
36
|
"QS-RAMA-04": {
|
|
34
37
|
name: "Rama",
|
|
35
38
|
role: "EMPLOYEE",
|
|
36
|
-
description: "Developer. Feature branches only. Cannot push to main
|
|
39
|
+
description: "Developer. Feature branches only. Cannot push to main.",
|
|
37
40
|
},
|
|
38
41
|
"QS-SALLY-05": {
|
|
39
42
|
name: "Sally",
|
|
40
43
|
role: "EMPLOYEE",
|
|
41
|
-
description: "Developer. Feature branches only. Cannot push to main
|
|
44
|
+
description: "Developer. Feature branches only. Cannot push to main.",
|
|
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;
|
|
@@ -65,14 +81,34 @@ function copy(src, dest) {
|
|
|
65
81
|
fs.copyFileSync(src, dest);
|
|
66
82
|
}
|
|
67
83
|
|
|
84
|
+
// ─── Branded Header ─────────────────────────────────────
|
|
85
|
+
const BOLD = "\x1b[1m";
|
|
86
|
+
const TEAL_GLOW = "\x1b[38;2;0;170;175m";
|
|
87
|
+
const PKG_VERSION = require("../package.json").version;
|
|
88
|
+
const RULE = "━".repeat(48);
|
|
89
|
+
|
|
90
|
+
function printHeader() {
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log("");
|
|
93
|
+
console.log(` ${TEAL}${BOLD}⬢ Q U A L I A${RESET}`);
|
|
94
|
+
console.log(` ${DIM}${RULE}${RESET}`);
|
|
95
|
+
console.log(` ${WHITE}Framework v${PKG_VERSION}${RESET} ${DIM}·${RESET} ${TEAL_GLOW}Qualia Solutions${RESET}`);
|
|
96
|
+
console.log(` ${DIM}Plan → Build → Verify → Ship${RESET}`);
|
|
97
|
+
console.log(` ${DIM}${RULE}${RESET}`);
|
|
98
|
+
console.log("");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function printSection(title) {
|
|
102
|
+
console.log("");
|
|
103
|
+
console.log(` ${TEAL}▸${RESET} ${WHITE}${BOLD}${title}${RESET}`);
|
|
104
|
+
console.log(` ${DIM}${"─".repeat(40)}${RESET}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
68
107
|
// ─── Prompt for code ─────────────────────────────────────
|
|
69
108
|
function askCode() {
|
|
70
109
|
return new Promise((resolve) => {
|
|
71
110
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
72
|
-
|
|
73
|
-
console.log(`${TEAL} ⬢ Qualia Framework v2${RESET}`);
|
|
74
|
-
console.log(`${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
|
|
75
|
-
console.log("");
|
|
111
|
+
printHeader();
|
|
76
112
|
rl.question(` ${WHITE}Enter install code:${RESET} `, (answer) => {
|
|
77
113
|
rl.close();
|
|
78
114
|
resolve(answer.trim());
|
|
@@ -111,10 +147,9 @@ async function main() {
|
|
|
111
147
|
}
|
|
112
148
|
|
|
113
149
|
console.log("");
|
|
114
|
-
|
|
115
|
-
console.log(
|
|
116
|
-
log(`
|
|
117
|
-
console.log("");
|
|
150
|
+
const roleColor = member.role === "OWNER" ? TEAL : GREEN;
|
|
151
|
+
console.log(` ${GREEN}✓${RESET} ${WHITE}${BOLD}Welcome, ${member.name}${RESET}`);
|
|
152
|
+
console.log(` ${DIM} Role:${RESET} ${roleColor}${member.role}${RESET} ${DIM}·${RESET} ${DIM}Target:${RESET} ${WHITE}${CLAUDE_DIR}${RESET}`);
|
|
118
153
|
|
|
119
154
|
// ─── Skills ──────────────────────────────────────────
|
|
120
155
|
const skillsDir = path.join(FRAMEWORK_DIR, "skills");
|
|
@@ -122,7 +157,7 @@ async function main() {
|
|
|
122
157
|
.readdirSync(skillsDir)
|
|
123
158
|
.filter((d) => fs.statSync(path.join(skillsDir, d)).isDirectory());
|
|
124
159
|
|
|
125
|
-
|
|
160
|
+
printSection("Skills");
|
|
126
161
|
for (const skill of skills) {
|
|
127
162
|
try {
|
|
128
163
|
copy(
|
|
@@ -136,7 +171,7 @@ async function main() {
|
|
|
136
171
|
}
|
|
137
172
|
|
|
138
173
|
// ─── Agents ────────────────────────────────────────────
|
|
139
|
-
|
|
174
|
+
printSection("Agents");
|
|
140
175
|
const agentsDir = path.join(FRAMEWORK_DIR, "agents");
|
|
141
176
|
for (const file of fs.readdirSync(agentsDir)) {
|
|
142
177
|
try {
|
|
@@ -148,7 +183,7 @@ async function main() {
|
|
|
148
183
|
}
|
|
149
184
|
|
|
150
185
|
// ─── Rules ─────────────────────────────────────────────
|
|
151
|
-
|
|
186
|
+
printSection("Rules");
|
|
152
187
|
const rulesDir = path.join(FRAMEWORK_DIR, "rules");
|
|
153
188
|
for (const file of fs.readdirSync(rulesDir)) {
|
|
154
189
|
try {
|
|
@@ -160,24 +195,26 @@ async function main() {
|
|
|
160
195
|
}
|
|
161
196
|
|
|
162
197
|
// ─── Hooks ─────────────────────────────────────────────
|
|
163
|
-
|
|
198
|
+
printSection("Hooks");
|
|
164
199
|
const hooksSource = path.join(FRAMEWORK_DIR, "hooks");
|
|
165
200
|
const hooksDest = path.join(CLAUDE_DIR, "hooks");
|
|
166
201
|
if (!fs.existsSync(hooksDest)) fs.mkdirSync(hooksDest, { recursive: true });
|
|
167
|
-
// Clean up legacy .sh hooks from previous installs so no orphans
|
|
168
|
-
// remain on disk after upgrading to the pure-Node hooks.
|
|
169
|
-
// Also purge explicitly-deprecated hooks by name (these are no longer
|
|
170
|
-
// shipped in framework/hooks/ but may linger on existing installs).
|
|
171
|
-
const DEPRECATED_HOOKS = [
|
|
172
|
-
"block-env-edit.js", // v3.2.0: team decision to unblock .env read/write
|
|
173
|
-
];
|
|
202
|
+
// Clean up legacy .sh hooks from previous v2.5/v2.6 installs so no orphans
|
|
203
|
+
// remain on disk after upgrading to the pure-Node v2.7+ hooks.
|
|
174
204
|
try {
|
|
175
205
|
for (const f of fs.readdirSync(hooksDest)) {
|
|
176
|
-
if (f.endsWith(".sh")
|
|
206
|
+
if (f.endsWith(".sh")) {
|
|
177
207
|
try { fs.unlinkSync(path.join(hooksDest, f)); } catch {}
|
|
178
208
|
}
|
|
179
209
|
}
|
|
180
210
|
} catch {}
|
|
211
|
+
// v3.2.0: purge deprecated hooks from existing installs on upgrade.
|
|
212
|
+
// block-env-edit.js was retired — team now has full read/write on .env*.
|
|
213
|
+
const DEPRECATED_HOOKS = ["block-env-edit.js"];
|
|
214
|
+
for (const f of DEPRECATED_HOOKS) {
|
|
215
|
+
const p = path.join(hooksDest, f);
|
|
216
|
+
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch {}
|
|
217
|
+
}
|
|
181
218
|
for (const file of fs.readdirSync(hooksSource)) {
|
|
182
219
|
try {
|
|
183
220
|
const dest = path.join(hooksDest, file);
|
|
@@ -190,18 +227,8 @@ async function main() {
|
|
|
190
227
|
}
|
|
191
228
|
}
|
|
192
229
|
|
|
193
|
-
// ─── Status line ───────────────────────────────────────
|
|
194
|
-
log(`${WHITE}Status line${RESET}`);
|
|
195
|
-
try {
|
|
196
|
-
const slDest = path.join(CLAUDE_DIR, "bin", "statusline.js");
|
|
197
|
-
copy(path.join(FRAMEWORK_DIR, "bin", "statusline.js"), slDest);
|
|
198
|
-
ok("statusline.js");
|
|
199
|
-
} catch (e) {
|
|
200
|
-
warn(`statusline.js — ${e.message}`);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
230
|
// ─── Templates ─────────────────────────────────────────
|
|
204
|
-
|
|
231
|
+
printSection("Templates");
|
|
205
232
|
const tmplDir = path.join(FRAMEWORK_DIR, "templates");
|
|
206
233
|
const tmplDest = path.join(CLAUDE_DIR, "qualia-templates");
|
|
207
234
|
if (!fs.existsSync(tmplDest)) fs.mkdirSync(tmplDest, { recursive: true });
|
|
@@ -215,7 +242,7 @@ async function main() {
|
|
|
215
242
|
}
|
|
216
243
|
|
|
217
244
|
// ─── CLAUDE.md with role ───────────────────────────────
|
|
218
|
-
|
|
245
|
+
printSection("Configuration");
|
|
219
246
|
try {
|
|
220
247
|
let claudeMd = fs.readFileSync(
|
|
221
248
|
path.join(FRAMEWORK_DIR, "CLAUDE.md"),
|
|
@@ -231,7 +258,7 @@ async function main() {
|
|
|
231
258
|
}
|
|
232
259
|
|
|
233
260
|
// ─── Scripts ─────────────────────────────────────────────
|
|
234
|
-
|
|
261
|
+
printSection("Scripts");
|
|
235
262
|
try {
|
|
236
263
|
const binDest = path.join(CLAUDE_DIR, "bin");
|
|
237
264
|
if (!fs.existsSync(binDest)) fs.mkdirSync(binDest, { recursive: true });
|
|
@@ -267,7 +294,7 @@ async function main() {
|
|
|
267
294
|
}
|
|
268
295
|
|
|
269
296
|
// ─── Knowledge directory ─────────────────────────────────
|
|
270
|
-
|
|
297
|
+
printSection("Knowledge Base");
|
|
271
298
|
const knowledgeDir = path.join(CLAUDE_DIR, "knowledge");
|
|
272
299
|
if (!fs.existsSync(knowledgeDir)) fs.mkdirSync(knowledgeDir, { recursive: true });
|
|
273
300
|
const knowledgeFiles = {
|
|
@@ -333,7 +360,7 @@ Recurring issues and their solutions.
|
|
|
333
360
|
**Symptom:** \`/qualia-ship\` is blocked with "service_role found in client code" for a file that's actually a Server Component (runs server-side only).
|
|
334
361
|
**Cause:** pre-deploy-gate.js skips files matching \`.server.\` filename pattern OR \`server/\` directory path. If the Server Component is at \`app/admin/page.tsx\` (no .server. marker, not in a server/ dir), the scan flags it.
|
|
335
362
|
**Workaround:** Rename to \`.server.tsx\` OR move to a \`server/\` subdirectory OR extract the service_role usage into a helper in \`lib/server/\`.
|
|
336
|
-
**Framework version:** Known issue; better heuristic planned for
|
|
363
|
+
**Framework version:** Known issue as of v2.8.1; better heuristic planned for v3.0.
|
|
337
364
|
`,
|
|
338
365
|
"client-prefs.md": `# Client Preferences
|
|
339
366
|
|
|
@@ -368,11 +395,16 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
368
395
|
role: member.role,
|
|
369
396
|
version: require("../package.json").version,
|
|
370
397
|
installed_at: new Date().toISOString().split("T")[0],
|
|
398
|
+
erp: {
|
|
399
|
+
enabled: true,
|
|
400
|
+
url: "https://portal.qualiasolutions.net",
|
|
401
|
+
api_key_file: ".erp-api-key",
|
|
402
|
+
},
|
|
371
403
|
};
|
|
372
404
|
fs.writeFileSync(configFile, JSON.stringify(config, null, 2) + "\n");
|
|
373
405
|
|
|
374
406
|
// ─── ERP API key (for report uploads) ──────────────────
|
|
375
|
-
|
|
407
|
+
printSection("ERP Integration");
|
|
376
408
|
const erpKeyFile = path.join(CLAUDE_DIR, ".erp-api-key");
|
|
377
409
|
if (!fs.existsSync(erpKeyFile)) {
|
|
378
410
|
fs.writeFileSync(erpKeyFile, "qualia-claude-2026", { mode: 0o600 });
|
|
@@ -474,7 +506,7 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
474
506
|
type: "command",
|
|
475
507
|
if: "Bash(git push*)",
|
|
476
508
|
command: nodeCmd("branch-guard.js"),
|
|
477
|
-
timeout:
|
|
509
|
+
timeout: 5,
|
|
478
510
|
statusMessage: "⬢ Checking branch permissions...",
|
|
479
511
|
},
|
|
480
512
|
{
|
|
@@ -521,38 +553,44 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
521
553
|
],
|
|
522
554
|
};
|
|
523
555
|
|
|
524
|
-
// Permissions
|
|
556
|
+
// Permissions — no restrictions on env files or branches.
|
|
557
|
+
// Everyone can read/write .env, push to main.
|
|
525
558
|
if (!settings.permissions) settings.permissions = {};
|
|
526
559
|
if (!settings.permissions.allow) settings.permissions.allow = [];
|
|
527
|
-
if (!settings.permissions.deny)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
560
|
+
if (!settings.permissions.deny) settings.permissions.deny = [];
|
|
561
|
+
|
|
562
|
+
// ─── Optional: next-devtools MCP ─────────────────────────
|
|
563
|
+
// Wire next-devtools-mcp for runtime error visibility in Next.js projects
|
|
564
|
+
if (!settings.mcpServers) settings.mcpServers = {};
|
|
565
|
+
if (!settings.mcpServers["next-devtools"]) {
|
|
566
|
+
settings.mcpServers["next-devtools"] = {
|
|
567
|
+
command: "npx",
|
|
568
|
+
args: ["next-devtools-mcp@0.3.10"],
|
|
569
|
+
disabled: false,
|
|
570
|
+
};
|
|
571
|
+
ok("MCP: next-devtools (runtime error visibility for Next.js projects)");
|
|
533
572
|
}
|
|
534
573
|
|
|
535
574
|
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
|
|
536
575
|
|
|
537
|
-
ok("Hooks: session-start, auto-update, branch-guard, pre-push,
|
|
576
|
+
ok("Hooks: session-start, auto-update, branch-guard, pre-push, migration-guard, deploy-gate, pre-compact");
|
|
538
577
|
ok("Status line + spinner configured");
|
|
539
578
|
ok("Environment variables + permissions");
|
|
540
579
|
|
|
541
580
|
// ─── Summary ───────────────────────────────────────────
|
|
542
581
|
console.log("");
|
|
543
|
-
console.log(
|
|
544
|
-
console.log(
|
|
545
|
-
console.log(` ${
|
|
546
|
-
console.log(
|
|
582
|
+
console.log(` ${DIM}${RULE}${RESET}`);
|
|
583
|
+
console.log(` ${TEAL}${BOLD}⬢ INSTALLED${RESET}`);
|
|
584
|
+
console.log(` ${DIM}${RULE}${RESET}`);
|
|
585
|
+
console.log("");
|
|
586
|
+
console.log(` ${WHITE}${BOLD}${member.name}${RESET} ${DIM}·${RESET} ${roleColor}${member.role}${RESET} ${DIM}·${RESET} ${DIM}v${PKG_VERSION}${RESET}`);
|
|
587
|
+
console.log("");
|
|
547
588
|
const agentCount = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md')).length;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
console.log(`
|
|
552
|
-
console.log(`
|
|
553
|
-
console.log(` Templates: ${WHITE}${fs.readdirSync(tmplDir).length}${RESET}`);
|
|
554
|
-
console.log(` Status line: ${GREEN}✓${RESET}`);
|
|
555
|
-
console.log(` CLAUDE.md: ${GREEN}✓${RESET} ${DIM}(${member.role})${RESET}`);
|
|
589
|
+
const hookCount = fs.readdirSync(hooksSource).length;
|
|
590
|
+
const ruleCount = fs.readdirSync(rulesDir).length;
|
|
591
|
+
const tmplCount = fs.readdirSync(tmplDir).length;
|
|
592
|
+
console.log(` ${DIM}Skills${RESET} ${TEAL}${skills.length}${RESET} ${DIM}Agents${RESET} ${TEAL}${agentCount}${RESET} ${DIM}Hooks${RESET} ${TEAL}${hookCount}${RESET}`);
|
|
593
|
+
console.log(` ${DIM}Rules${RESET} ${TEAL}${ruleCount}${RESET} ${DIM}Scripts${RESET} ${TEAL}3${RESET} ${DIM}Templates${RESET} ${TEAL}${tmplCount}${RESET}`);
|
|
556
594
|
|
|
557
595
|
if (errors > 0) {
|
|
558
596
|
console.log("");
|
|
@@ -560,7 +598,22 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
560
598
|
}
|
|
561
599
|
|
|
562
600
|
console.log("");
|
|
563
|
-
console.log(`
|
|
601
|
+
console.log(` ${DIM}${RULE}${RESET}`);
|
|
602
|
+
console.log(` ${WHITE}${BOLD}Quick Start${RESET}`);
|
|
603
|
+
console.log(` ${DIM}${RULE}${RESET}`);
|
|
604
|
+
console.log("");
|
|
605
|
+
console.log(` ${TEAL}1.${RESET} ${WHITE}Restart Claude Code${RESET} ${DIM}(loads new settings)${RESET}`);
|
|
606
|
+
console.log(` ${TEAL}2.${RESET} ${WHITE}cd into any project${RESET} ${DIM}and run${RESET} ${TEAL}claude${RESET}`);
|
|
607
|
+
console.log(` ${TEAL}3.${RESET} ${WHITE}Type${RESET} ${TEAL}${BOLD}/qualia${RESET} ${DIM}— it tells you what to do next${RESET}`);
|
|
608
|
+
console.log("");
|
|
609
|
+
console.log(` ${DIM}New project?${RESET} ${TEAL}/qualia-new${RESET}`);
|
|
610
|
+
console.log(` ${DIM}Quick fix?${RESET} ${TEAL}/qualia-quick${RESET}`);
|
|
611
|
+
console.log(` ${DIM}End of day?${RESET} ${TEAL}/qualia-report${RESET} ${DIM}(mandatory)${RESET}`);
|
|
612
|
+
console.log(` ${DIM}Stuck?${RESET} ${TEAL}/qualia${RESET}`);
|
|
613
|
+
console.log("");
|
|
614
|
+
console.log(` ${DIM}${RULE}${RESET}`);
|
|
615
|
+
console.log(` ${TEAL}${BOLD}Welcome to the future with Qualia.${RESET}`);
|
|
616
|
+
console.log(` ${DIM}${RULE}${RESET}`);
|
|
564
617
|
console.log("");
|
|
565
618
|
}
|
|
566
619
|
|
package/bin/qualia-ui.js
CHANGED
|
@@ -249,6 +249,16 @@ function cmdNext(cmd) {
|
|
|
249
249
|
console.log("");
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
function cmdEnd(status, nextCmd) {
|
|
253
|
+
console.log("");
|
|
254
|
+
console.log(` ${TEAL}${BOLD}⬢${RESET} ${WHITE}${BOLD}${status || "DONE"}${RESET}`);
|
|
255
|
+
console.log(` ${RULE_DIM}`);
|
|
256
|
+
if (nextCmd) {
|
|
257
|
+
console.log(` ${TEAL}⟶${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${nextCmd}${RESET}`);
|
|
258
|
+
}
|
|
259
|
+
console.log("");
|
|
260
|
+
}
|
|
261
|
+
|
|
252
262
|
function cmdUpdate(current, latest) {
|
|
253
263
|
if (!current || !latest) return;
|
|
254
264
|
console.log("");
|
|
@@ -262,16 +272,6 @@ function cmdUpdate(current, latest) {
|
|
|
262
272
|
console.log("");
|
|
263
273
|
}
|
|
264
274
|
|
|
265
|
-
function cmdEnd(status, nextCmd) {
|
|
266
|
-
console.log("");
|
|
267
|
-
console.log(` ${TEAL}${BOLD}⬢${RESET} ${WHITE}${BOLD}${status || "DONE"}${RESET}`);
|
|
268
|
-
console.log(` ${RULE_DIM}`);
|
|
269
|
-
if (nextCmd) {
|
|
270
|
-
console.log(` ${TEAL}⟶${RESET} ${WHITE}Next:${RESET} ${TEAL}${BOLD}${nextCmd}${RESET}`);
|
|
271
|
-
}
|
|
272
|
-
console.log("");
|
|
273
|
-
}
|
|
274
|
-
|
|
275
275
|
// ─── Main ────────────────────────────────────────────────
|
|
276
276
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
277
277
|
switch (cmd) {
|
|
@@ -293,7 +293,7 @@ switch (cmd) {
|
|
|
293
293
|
case "update": cmdUpdate(rest[0], rest[1]); break;
|
|
294
294
|
default:
|
|
295
295
|
console.error(
|
|
296
|
-
`Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end> [args]`
|
|
296
|
+
`Usage: qualia-ui.js <banner|context|divider|ok|fail|warn|info|spawn|wave|task|done|next|end|update> [args]`
|
|
297
297
|
);
|
|
298
298
|
process.exit(1);
|
|
299
299
|
}
|
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
|
-
|
|
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)
|
|
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
|
}
|