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/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 TEAM = {
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 or edit .env files.",
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 or edit .env files.",
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 or edit .env files.",
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 or edit .env files.",
44
+ description: "Developer. Feature branches only. Cannot push to main.",
42
45
  },
43
46
  };
44
47
 
45
- const CLAUDE_DIR = path.join(require("os").homedir(), ".claude");
46
- const FRAMEWORK_DIR = path.resolve(__dirname, "..");
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
- console.log("");
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
- log(`${GREEN}✓${RESET} ${WHITE}${member.name}${RESET} ${DIM}(${member.role})${RESET}`);
115
- console.log("");
116
- log(`Installing to ${WHITE}${CLAUDE_DIR}${RESET}`);
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
- log(`${WHITE}Skills${RESET}`);
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
- log(`${WHITE}Agents${RESET}`);
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
- log(`${WHITE}Rules${RESET}`);
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
- log(`${WHITE}Hooks${RESET}`);
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") || DEPRECATED_HOOKS.includes(f)) {
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
- log(`${WHITE}Templates${RESET}`);
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
- log(`${WHITE}CLAUDE.md${RESET}`);
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
- log(`${WHITE}Scripts${RESET}`);
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
- log(`${WHITE}Knowledge${RESET}`);
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 a future release.
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
- log(`${WHITE}ERP integration${RESET}`);
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: 10,
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
- settings.permissions.deny = [
529
- "Read(./.env)",
530
- "Read(./.env.*)",
531
- "Read(./secrets/**)",
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, env-block, migration-guard, deploy-gate, pre-compact");
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(`${TEAL} ⬢ Installed ✓${RESET}`);
544
- console.log(`${DIM} ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
545
- console.log(` ${WHITE}${member.name}${RESET} ${DIM}(${member.role})${RESET}`);
546
- console.log(` Skills: ${WHITE}${skills.length}${RESET}`);
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
- console.log(` Agents: ${WHITE}${agentCount}${RESET} ${DIM}(planner, builder, verifier, qa-browser)${RESET}`);
549
- console.log(` Hooks: ${WHITE}8${RESET} ${DIM}(session-start, auto-update, branch-guard, pre-push, env-block, migration-guard, deploy-gate, pre-compact)${RESET}`);
550
- console.log(` Rules: ${WHITE}${fs.readdirSync(rulesDir).length}${RESET} ${DIM}(security, frontend, design-reference, deployment)${RESET}`);
551
- console.log(` Scripts: ${WHITE}3${RESET} ${DIM}(state.js, qualia-ui.js, statusline.js)${RESET}`);
552
- console.log(` Knowledge: ${WHITE}3${RESET} ${DIM}(patterns, fixes, client prefs)${RESET}`);
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(` Restart Claude Code, then type ${TEAL}/qualia${RESET} in any project.`);
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
- if (cycles >= 2) {
273
+ const limit = getGapCycleLimit();
274
+ if (cycles >= limit) {
236
275
  return fail(
237
276
  "GAP_CYCLE_LIMIT",
238
- `Phase ${phase} has failed verification ${cycles} times. Escalate to Fawzi or re-plan from scratch.`
277
+ `Phase ${phase} has failed verification ${cycles} times (limit: ${limit}). Escalate to Fawzi or re-plan from scratch.`
239
278
  );
240
279
  }
241
280
  }
@@ -294,6 +333,7 @@ function cmdCheck(opts) {
294
333
  assigned_to: s.assigned_to,
295
334
  verification: t.verification || "pending",
296
335
  gap_cycles: (t.gap_cycles || {})[String(s.phase)] || 0,
336
+ gap_cycle_limit: getGapCycleLimit(),
297
337
  tasks_done: t.tasks_done || 0,
298
338
  tasks_total: t.tasks_total || 0,
299
339
  deployed_url: t.deployed_url || "",
@@ -331,7 +371,6 @@ function cmdTransition(opts) {
331
371
 
332
372
  // Special: note/activity (no status change)
333
373
  if (target === "note" || target === "activity") {
334
- const now = new Date().toISOString().split("T")[0];
335
374
  if (opts.notes) t.notes = opts.notes;
336
375
  t.last_updated = new Date().toISOString();
337
376
  writeTracking(t);
@@ -353,7 +392,16 @@ function cmdTransition(opts) {
353
392
  target,
354
393
  { ...opts, phase }
355
394
  );
356
- if (!check.ok) return output(check);
395
+ if (!check.ok) {
396
+ // Force only bypasses status-ordering errors (PRECONDITION_FAILED, GAP_CYCLE_LIMIT).
397
+ // Never bypass MISSING_FILE, MISSING_ARG, INVALID_PLAN — those cause broken state.
398
+ const forceableErrors = ["PRECONDITION_FAILED", "GAP_CYCLE_LIMIT"];
399
+ if (opts.force && forceableErrors.includes(check.error)) {
400
+ console.error(`WARNING: Forcing transition despite: ${check.message}`);
401
+ } else {
402
+ return output(check);
403
+ }
404
+ }
357
405
 
358
406
  const prevStatus = s.status;
359
407
 
@@ -432,6 +480,18 @@ function cmdTransition(opts) {
432
480
  return output(fail("WRITE_ERROR", e.message));
433
481
  }
434
482
 
483
+ // Skill outcome scoring — log transition for analytics
484
+ _trace("state-transition", {
485
+ result: "allow",
486
+ phase: s.phase,
487
+ status: s.status,
488
+ previous_status: prevStatus,
489
+ verification: t.verification,
490
+ gap_closure: prevStatus === "verified" && target === "planned",
491
+ duration_ms: 0,
492
+ extra: { verification: t.verification, gap_closure: prevStatus === "verified" && target === "planned" }
493
+ });
494
+
435
495
  output({
436
496
  ok: true,
437
497
  phase: s.phase,
@@ -597,6 +657,137 @@ function cmdFix(opts) {
597
657
  });
598
658
  }
599
659
 
660
+ function cmdValidatePlan(opts) {
661
+ const phase = parseInt(opts.phase) || 1;
662
+ const planFile = path.join(PLANNING, `phase-${phase}-plan.md`);
663
+
664
+ if (!fs.existsSync(planFile)) {
665
+ return output(fail("MISSING_FILE", `Plan file not found: ${planFile}`));
666
+ }
667
+
668
+ const content = fs.readFileSync(planFile, "utf8");
669
+ const errors = [];
670
+
671
+ // Check frontmatter exists
672
+ if (!/^---\n/.test(content)) {
673
+ errors.push("Missing frontmatter (---) at start of file");
674
+ }
675
+
676
+ // Check task count > 0
677
+ const taskHeaders = content.match(/^## Task \d+/gm);
678
+ if (!taskHeaders || taskHeaders.length === 0) {
679
+ errors.push("No task headers found (expected '## Task N — title')");
680
+ }
681
+
682
+ // Check "Done when" exists for each task
683
+ const taskCount = taskHeaders ? taskHeaders.length : 0;
684
+ const doneWhenCount = (content.match(/\*\*Done when:\*\*/g) || []).length;
685
+ if (doneWhenCount < taskCount) {
686
+ errors.push(
687
+ `${taskCount} tasks but only ${doneWhenCount} 'Done when:' entries`
688
+ );
689
+ }
690
+
691
+ // Check Success Criteria section exists
692
+ if (!/## Success Criteria/m.test(content)) {
693
+ errors.push("Missing '## Success Criteria' section");
694
+ }
695
+
696
+ // Check goal in frontmatter
697
+ if (!/^goal:/m.test(content)) {
698
+ errors.push("Missing 'goal:' in frontmatter");
699
+ }
700
+
701
+ // ─── Verification Contract Validation (non-blocking) ────
702
+ const warnings = [];
703
+ const VALID_CHECK_TYPES = ["file-exists", "grep-match", "command-exit", "behavioral"];
704
+ let contractCount = 0;
705
+
706
+ if (/^## Verification Contract/m.test(content)) {
707
+ // Extract the contract section (from header to next ## or end of file)
708
+ const contractSectionMatch = content.match(
709
+ /^## Verification Contract\s*\n([\s\S]+)/m
710
+ );
711
+ if (contractSectionMatch) {
712
+ // Trim at the next ## heading that isn't ### (i.e., a new top-level section)
713
+ let contractSection = contractSectionMatch[1];
714
+ const nextH2 = contractSection.search(/\n## (?!#)/);
715
+ if (nextH2 !== -1) contractSection = contractSection.substring(0, nextH2);
716
+ // Each contract starts with ### Contract for Task N
717
+ const contractBlocks = contractSection.match(/^### Contract for Task \d+/gm);
718
+ contractCount = contractBlocks ? contractBlocks.length : 0;
719
+
720
+ if (contractCount === 0) {
721
+ warnings.push("Verification Contract section exists but contains no contract blocks (expected '### Contract for Task N')");
722
+ } else {
723
+ // Split into individual contract blocks for validation
724
+ const blockSplits = contractSection.split(/^(?=### Contract for Task \d+)/m).filter(Boolean);
725
+ for (const block of blockSplits) {
726
+ const taskNumMatch = block.match(/^### Contract for Task (\d+)/);
727
+ if (!taskNumMatch) continue;
728
+ const taskNum = taskNumMatch[1];
729
+
730
+ const checkTypeMatch = block.match(/\*\*Check type:\*\*\s*(.+)/);
731
+ const hasCommand = /\*\*Command:\*\*/.test(block);
732
+ const hasExpected = /\*\*Expected:\*\*/.test(block);
733
+ const hasFailIf = /\*\*Fail if:\*\*/.test(block);
734
+
735
+ if (!checkTypeMatch) {
736
+ warnings.push(`Contract for Task ${taskNum}: missing 'Check type'`);
737
+ } else {
738
+ const checkType = checkTypeMatch[1].trim().toLowerCase();
739
+ if (!VALID_CHECK_TYPES.includes(checkType)) {
740
+ warnings.push(
741
+ `Contract for Task ${taskNum}: invalid check type '${checkType}' (valid: ${VALID_CHECK_TYPES.join(", ")})`
742
+ );
743
+ }
744
+ // behavioral type doesn't require Command or Expected
745
+ const isBehavioral = checkType === "behavioral";
746
+ if (!isBehavioral && !hasCommand) {
747
+ warnings.push(`Contract for Task ${taskNum}: missing 'Command' (required for ${checkType})`);
748
+ }
749
+ if (!isBehavioral && !hasExpected) {
750
+ warnings.push(`Contract for Task ${taskNum}: missing 'Expected' (required for ${checkType})`);
751
+ }
752
+ }
753
+
754
+ if (!hasFailIf) {
755
+ warnings.push(`Contract for Task ${taskNum}: missing 'Fail if'`);
756
+ }
757
+ }
758
+ }
759
+
760
+ // Warn if contract count < task count
761
+ if (taskCount > 0 && contractCount > 0 && contractCount < taskCount) {
762
+ warnings.push(
763
+ `Only ${contractCount} contract(s) for ${taskCount} task(s) — not all tasks have verification contracts`
764
+ );
765
+ }
766
+ }
767
+ }
768
+
769
+ if (errors.length > 0) {
770
+ return output({
771
+ ok: false,
772
+ error: "PLAN_VALIDATION_FAILED",
773
+ phase,
774
+ errors,
775
+ warnings: warnings.length > 0 ? warnings : undefined,
776
+ message: `Plan file has ${errors.length} issue(s)`,
777
+ });
778
+ }
779
+
780
+ output({
781
+ ok: true,
782
+ action: "validate-plan",
783
+ phase,
784
+ task_count: taskCount,
785
+ done_when_count: doneWhenCount,
786
+ contract_count: contractCount,
787
+ warnings: warnings.length > 0 ? warnings : undefined,
788
+ });
789
+ }
790
+
600
791
  // ─── Output ──────────────────────────────────────────────
601
792
  function output(obj) {
602
793
  console.log(JSON.stringify(obj, null, 2));
@@ -620,11 +811,14 @@ switch (cmd) {
620
811
  case "fix":
621
812
  cmdFix(opts);
622
813
  break;
814
+ case "validate-plan":
815
+ cmdValidatePlan(opts);
816
+ break;
623
817
  default:
624
818
  output(
625
819
  fail(
626
820
  "UNKNOWN_COMMAND",
627
- `Usage: state.js <check|transition|init|fix> [--options]`
821
+ `Usage: state.js <check|transition|init|fix|validate-plan> [--options]`
628
822
  )
629
823
  );
630
824
  }