qualia-framework 7.2.1 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,6 +26,7 @@
26
26
 
27
27
  const fs = require("fs");
28
28
  const path = require("path");
29
+ const { spawnSync } = require("child_process");
29
30
 
30
31
  const SEVERITY_WEIGHT = { CRITICAL: 8, HIGH: 4, MEDIUM: 2, LOW: 1 };
31
32
  const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1 };
@@ -133,6 +134,115 @@ function aggregate(panel) {
133
134
  };
134
135
  }
135
136
 
137
+ // ── gate recorder + verdict aggregator (machine-JSON gate fold) ─────────────
138
+ // WHY: the verdict was an LLM reading prose ("phase is PASS only if ALL of these
139
+ // agree…") and ANDing exit codes by hand — non-deterministic, and a dropped exit
140
+ // code silently flips a CRITICAL. This makes the COMBINE deterministic without
141
+ // changing what blocks: each gate's EXISTING block condition (the exit code it
142
+ // already returns) is captured MECHANICALLY into one normalized artifact, and a
143
+ // single `verdict {N}` call folds those gates + the panel into one PASS/FAIL with
144
+ // the SAME severity weighting aggregate() already uses. No new blocking — the
145
+ // aggregator only makes the existing AND deterministic.
146
+
147
+ // Normalized gate-result artifact, sibling to phase-{N}-panel-{lens}.json:
148
+ // .planning/phase-{N}-gate-{name}.json
149
+ // = { gate, severity, blocking, findings: [{file,line,severity,title}] }
150
+ // blocking=true means a surviving finding from this gate flips the verdict; the
151
+ // findings array reuses the exact {file,line,severity,title} shape assemble()
152
+ // consumes, so the gate folds into the panel with zero special-casing.
153
+
154
+ // Each gate's EXISTING block condition → the severity the recorder stamps when
155
+ // the gate signals a fault. This map is the LOCKED, ratifiable default (ADR-0002):
156
+ // - dep-verify : exit 1 = a hallucinated/slopsquatted import → CRITICAL,
157
+ // blocking. Auto-survives (no skeptic — a hallucinated import
158
+ // is not refutable), so this gate ships NO votes.
159
+ // - slop-detect : run as --severity=critical, so exit 1 = CRITICAL slop only
160
+ // → CRITICAL, blocking. (HIGH/MEDIUM slop never reaches here.)
161
+ // - harness-eval : a hard FAIL → CRITICAL, blocking. A soft/advisory sub-check
162
+ // is recorded MEDIUM, NON-blocking (use --severity MEDIUM).
163
+ // - qualia-eval : a suite FAIL → one CRITICAL per failing SUITE (the caller
164
+ // records one gate artifact per failing suite, name=eval-<suite>).
165
+ // A gate not in the map records as CRITICAL/blocking on non-zero (conservative).
166
+ const GATE_DEFAULTS = {
167
+ "dep-verify": { severity: "CRITICAL", blocking: true, skeptic: false },
168
+ "slop-detect": { severity: "CRITICAL", blocking: true, skeptic: false },
169
+ "harness-eval":{ severity: "CRITICAL", blocking: true, skeptic: false },
170
+ "qualia-eval": { severity: "CRITICAL", blocking: true, skeptic: false },
171
+ };
172
+
173
+ function gatePath(root, phase, name) {
174
+ return path.join(root, ".planning", `phase-${phase}-gate-${name}.json`);
175
+ }
176
+
177
+ // Record one gate's result from its REAL exit code. Non-zero exit = the gate's
178
+ // block condition fired → one finding at the mapped (or overridden) severity.
179
+ // Exit 0 = clean → empty findings (recorded, visible, non-blocking). The artifact
180
+ // is written from the exit code mechanically — never an LLM hand-writing JSON.
181
+ function recordGate(root, phase, name, exitCode, opts = {}) {
182
+ const def = GATE_DEFAULTS[name] || { severity: "CRITICAL", blocking: true };
183
+ const code = Number(exitCode);
184
+ const failed = !(Number.isFinite(code) && code === 0);
185
+ // --severity overrides the default (e.g. harness-eval soft sub-check → MEDIUM,
186
+ // which a non-blocking gate records but does not let flip the verdict).
187
+ const severity = normSeverity(opts.severity || def.severity);
188
+ // A non-CRITICAL/HIGH override is inherently non-blocking — a MEDIUM/LOW gate
189
+ // finding never flips the C/H verdict the aggregator gates on. Keep blocking
190
+ // true only when the severity can actually block, so the artifact is honest.
191
+ const canBlock = severity === "CRITICAL" || severity === "HIGH";
192
+ const blocking = (opts.blocking != null ? opts.blocking : def.blocking) && canBlock;
193
+ const title = opts.title ||
194
+ `${name} gate failed (exit ${Number.isFinite(code) ? code : "?"})`;
195
+ const findings = failed
196
+ ? [{ file: opts.file || `gate:${name}`, line: opts.line != null ? opts.line : null, severity, title }]
197
+ : [];
198
+ const artifact = { gate: name, severity, blocking, findings };
199
+ const out = gatePath(root, phase, name);
200
+ fs.mkdirSync(path.dirname(out), { recursive: true });
201
+ fs.writeFileSync(out, JSON.stringify(artifact, null, 2) + "\n");
202
+ return { out: path.relative(root, out), artifact, failed };
203
+ }
204
+
205
+ // Glob the normalized gate artifacts for a phase. Each becomes a pseudo-lens
206
+ // ("gate:<name>") whose findings carry the gate's severity. Non-blocking gates
207
+ // still surface their findings (visible) but their findings are recorded at a
208
+ // severity that cannot flip the verdict — honesty without regression.
209
+ function assembleGates(root, phase) {
210
+ const dir = path.join(root, ".planning");
211
+ const re = new RegExp(`^phase-${phase}-gate-([a-z0-9-]+)\\.json$`);
212
+ const lenses = [];
213
+ const findings = [];
214
+ let files = [];
215
+ try { files = fs.readdirSync(dir); } catch { files = []; }
216
+ for (const f of files.sort()) {
217
+ const m = f.match(re);
218
+ if (!m) continue;
219
+ const name = m[1];
220
+ let g;
221
+ try { g = JSON.parse(fs.readFileSync(path.join(dir, f), "utf8")); } catch { continue; }
222
+ if (!g || !Array.isArray(g.findings)) continue;
223
+ const lens = `gate:${name}`;
224
+ lenses.push(lens);
225
+ const blocking = g.blocking !== false;
226
+ for (const item of g.findings) {
227
+ // A non-blocking gate's findings are demoted to MEDIUM so the deterministic
228
+ // aggregate() (which fails only on surviving CRITICAL/HIGH) records them
229
+ // without ever flipping the verdict — the no-regression guarantee in code.
230
+ const sev = blocking ? normSeverity(item.severity || g.severity) : "MEDIUM";
231
+ findings.push({
232
+ lens,
233
+ file: item.file || `gate:${name}`,
234
+ line: item.line != null ? item.line : null,
235
+ severity: sev,
236
+ title: item.title || `${name} gate finding`,
237
+ // dep-verify/slop-detect gates auto-survive (no skeptic). Stamp a single
238
+ // real vote so a surviving CRITICAL is never mistaken for "unverified".
239
+ votes: { real: 1, notReal: 0 },
240
+ });
241
+ }
242
+ }
243
+ return { lenses, findings };
244
+ }
245
+
136
246
  // ── CLI ───────────────────────────────────────────────────────────────────
137
247
  function parseArgs(argv) {
138
248
  const args = { _: [] };
@@ -142,6 +252,14 @@ function parseArgs(argv) {
142
252
  else if (a === "--write") args.write = true;
143
253
  else if (a === "--cwd") args.cwd = argv[++i];
144
254
  else if (a.startsWith("--cwd=")) args.cwd = a.slice(6);
255
+ else if (a === "--exit") args.exit = argv[++i];
256
+ else if (a.startsWith("--exit=")) args.exit = a.slice(7);
257
+ else if (a === "--severity") args.severity = argv[++i];
258
+ else if (a.startsWith("--severity=")) args.severity = a.slice(11);
259
+ else if (a === "--title") args.title = argv[++i];
260
+ else if (a.startsWith("--title=")) args.title = a.slice(8);
261
+ else if (a === "--blocking") args.blocking = argv[++i];
262
+ else if (a.startsWith("--blocking=")) args.blocking = a.slice(11);
145
263
  else args._.push(a);
146
264
  }
147
265
  return args;
@@ -219,7 +337,289 @@ function assemble(root, phase) {
219
337
  return { phase: Number(phase), lenses, findings };
220
338
  }
221
339
 
340
+ // ── execution lens ──────────────────────────────────────────────────────────
341
+ // EXECUTION-GROUNDED verify: a grep proves a symbol EXISTS; it does not prove the
342
+ // feature RUNS. A builder can satisfy a grep pattern (exported name, string
343
+ // match) while the project doesn't compile or its tests fail — the dominant
344
+ // reward-hacking failure mode. This lens runs the project's OWN checks and writes
345
+ // findings in the same {file,line,severity,title} shape assemble() consumes, so a
346
+ // red build folds into the verdict as a CRITICAL the panel cannot grep around.
347
+ //
348
+ // Fail-soft vs fail-closed: a check whose tool is ABSENT (no tsconfig, no test
349
+ // script) is SKIPPED — its absence is not evidence of breakage. A check that is
350
+ // PRESENT but exits non-zero is a CRITICAL finding — present-and-red is real,
351
+ // hard evidence the goal isn't achieved. Same discipline as the constitution's
352
+ // gates: absent ⇒ inert, present-and-failing ⇒ block.
353
+
354
+ // One project check: a label, whether it applies to this repo, and the argv to
355
+ // run. `applies` keeps an absent tool from ever becoming a FAIL.
356
+ function executionChecks(root) {
357
+ const has = (p) => fs.existsSync(path.join(root, p));
358
+ let pkg = {};
359
+ try { pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); } catch { pkg = {}; }
360
+ const scripts = (pkg && pkg.scripts) || {};
361
+ return [
362
+ {
363
+ name: "typecheck",
364
+ title: "npx tsc --noEmit failed — project does not type-check",
365
+ applies: has("tsconfig.json"),
366
+ cmd: "npx",
367
+ args: ["tsc", "--noEmit"],
368
+ },
369
+ {
370
+ name: "test",
371
+ title: "npm test failed — the project's own test suite is red",
372
+ applies: typeof scripts.test === "string" && scripts.test.trim() !== "" &&
373
+ !/no test specified/i.test(scripts.test),
374
+ cmd: "npm",
375
+ args: ["test", "--silent"],
376
+ },
377
+ {
378
+ name: "build",
379
+ title: "npm run build failed — the project does not build",
380
+ applies: typeof scripts.build === "string" && scripts.build.trim() !== "",
381
+ cmd: "npm",
382
+ args: ["run", "build", "--silent"],
383
+ },
384
+ ];
385
+ }
386
+
387
+ // Run the applicable checks and return panel findings (CRITICAL per non-zero
388
+ // check) plus a per-check status report. Absent tool ⇒ SKIPPED (no finding);
389
+ // present + exit 0 ⇒ PASS (no finding); present + non-zero ⇒ CRITICAL finding.
390
+ // Spawn errors (tool missing on PATH, e.g. no npx) are treated as SKIPPED, not
391
+ // FAIL — fail-soft: we only fail-closed on a check we could actually run.
392
+ function runExecution(root, opts = {}) {
393
+ const timeout = opts.timeout_ms || 300_000;
394
+ const findings = [];
395
+ const report = [];
396
+ for (const c of executionChecks(root)) {
397
+ if (!c.applies) { report.push({ name: c.name, status: "SKIPPED" }); continue; }
398
+ const r = spawnSync(c.cmd, c.args, {
399
+ cwd: root,
400
+ encoding: "utf8",
401
+ timeout,
402
+ stdio: ["ignore", "pipe", "pipe"],
403
+ shell: false,
404
+ });
405
+ if (r.error) {
406
+ // Could not even launch the tool (ENOENT, etc.) — absent, not broken.
407
+ report.push({ name: c.name, status: "SKIPPED", detail: r.error.message });
408
+ continue;
409
+ }
410
+ const status = typeof r.status === "number" ? r.status : 1;
411
+ if (status === 0) { report.push({ name: c.name, status: "PASS" }); continue; }
412
+ const tail = (r.stderr || r.stdout || "").trim().split(/\r?\n/).slice(-3).join(" / ").slice(-300);
413
+ report.push({ name: c.name, status: "FAIL", exit: status, tail });
414
+ findings.push({
415
+ file: "package.json",
416
+ line: null,
417
+ severity: "CRITICAL",
418
+ title: `${c.title}${tail ? ` (exit ${status}: ${tail})` : ` (exit ${status})`}`,
419
+ });
420
+ }
421
+ return { findings, report };
422
+ }
423
+
424
+ // Write the execution lens's findings to phase-{N}-panel-execution.json — the
425
+ // SAME path+shape assemble() globs, so the execution lens folds into the panel
426
+ // with zero special-casing. Empty array when every check passed or was skipped.
427
+ function writeExecutionPanel(root, phase, opts = {}) {
428
+ const { findings, report } = runExecution(root, opts);
429
+ const dir = path.join(root, ".planning");
430
+ const out = path.join(dir, `phase-${phase}-panel-execution.json`);
431
+ fs.mkdirSync(dir, { recursive: true });
432
+ fs.writeFileSync(out, JSON.stringify(findings, null, 2) + "\n");
433
+ return { out: path.relative(root, out), findings, report };
434
+ }
435
+
436
+ // Tally skeptic reply lines into vote counts. One verdict per line: a line is
437
+ // a REAL vote if it starts with "REAL", a NOT_REAL vote if it starts with
438
+ // "NOT_REAL" (checked first so "NOT_REAL" isn't read as "REAL"). Blank lines
439
+ // and anything that isn't a verdict are ignored — the orchestrator pipes the
440
+ // skeptics' one-line replies straight in, so stray prose never miscounts.
441
+ // Pure function of its input ⇒ same lines → same counts (the determinism the
442
+ // panel exists to provide; no LLM hand-tally between the votes and the verdict).
443
+ function tallySkepticVotes(text) {
444
+ const votes = { real: 0, notReal: 0 };
445
+ for (const raw of String(text || "").split(/\r?\n/)) {
446
+ const line = raw.trim();
447
+ if (!line) continue;
448
+ if (/^NOT[_\s]?REAL\b/i.test(line)) votes.notReal++;
449
+ else if (/^REAL\b/i.test(line)) votes.real++;
450
+ }
451
+ return votes;
452
+ }
453
+
454
+ // Write the tallied votes onto the finding whose findingKey() matches `key`
455
+ // in the assembled panel.json. Reuses the same read/write + key conventions as
456
+ // dedupeFindings/assemble so the count the verdict sees is mechanical, not a
457
+ // hand-edit the orchestrator could miscount.
458
+ function writeSkepticVotes(root, phase, key, text) {
459
+ const panelPath = path.join(root, ".planning", `phase-${phase}-panel.json`);
460
+ let panel;
461
+ try {
462
+ panel = JSON.parse(fs.readFileSync(panelPath, "utf8"));
463
+ } catch (e) {
464
+ return { ok: false, error: `cannot read ${panelPath}: ${e.message}` };
465
+ }
466
+ const findings = Array.isArray(panel.findings) ? panel.findings : [];
467
+ const match = findings.find((f) => findingKey(f) === key);
468
+ if (!match) {
469
+ return { ok: false, error: `no finding matches key "${key}" in ${panelPath}`, keys: findings.map(findingKey) };
470
+ }
471
+ const votes = tallySkepticVotes(text);
472
+ match.votes = votes;
473
+ try {
474
+ fs.writeFileSync(panelPath, JSON.stringify(panel, null, 2) + "\n");
475
+ } catch (e) {
476
+ return { ok: false, error: `cannot write ${panelPath}: ${e.message}` };
477
+ }
478
+ return { ok: true, key, votes, path: panelPath };
479
+ }
480
+
222
481
  function main(argv) {
482
+ // skeptic subcommand: verify-panel.js skeptic <phase> <finding-key> [replies-file] [--cwd DIR]
483
+ // Reads skeptic reply lines (one REAL/NOT_REAL per line) from the file arg or
484
+ // stdin, tallies them deterministically, and writes votes.real/votes.notReal
485
+ // onto the matching finding in .planning/phase-{N}-panel.json.
486
+ if (argv[2] === "skeptic") {
487
+ const sub = parseArgs(["", "", ...argv.slice(3)]);
488
+ const phase = sub._[0];
489
+ const key = sub._[1];
490
+ const repliesFile = sub._[2];
491
+ if (phase == null || key == null) { usage(); return 2; }
492
+ const root = path.resolve(sub.cwd || process.cwd());
493
+ let text;
494
+ try {
495
+ text = repliesFile ? fs.readFileSync(repliesFile, "utf8") : fs.readFileSync(0, "utf8");
496
+ } catch (e) {
497
+ console.error(`ERROR: cannot read skeptic replies: ${e.message}`);
498
+ return 2;
499
+ }
500
+ const res = writeSkepticVotes(root, phase, key, text);
501
+ if (!res.ok) {
502
+ console.error(`ERROR: ${res.error}`);
503
+ if (res.keys) console.error(` available keys: ${res.keys.join(", ") || "(none)"}`);
504
+ return 2;
505
+ }
506
+ console.log(`tallied ${res.votes.real}✓/${res.votes.notReal}✗ → ${key} in ${path.relative(root, res.path)}`);
507
+ return 0;
508
+ }
509
+
510
+ // execution subcommand: verify-panel.js execution <phase> [--cwd DIR]
511
+ // Runs the project's own checks (tsc / test / build) and writes
512
+ // .planning/phase-{N}-panel-execution.json in assemble()'s shape. Exit code
513
+ // mirrors the result for shell callers: 1 if any present check failed (a
514
+ // CRITICAL was recorded), 0 otherwise (all passed or skipped). The written
515
+ // panel file is the authoritative artifact; the exit code is a convenience.
516
+ if (argv[2] === "execution") {
517
+ const sub = parseArgs(["", "", ...argv.slice(3)]);
518
+ const phase = sub._[0];
519
+ if (phase == null) { usage(); return 2; }
520
+ const root = path.resolve(sub.cwd || process.cwd());
521
+ let res;
522
+ try {
523
+ res = writeExecutionPanel(root, phase, {});
524
+ } catch (e) {
525
+ console.error(`ERROR: execution lens failed: ${e.message}`);
526
+ return 2;
527
+ }
528
+ const summary = res.report.map((r) => `${r.name}:${r.status}`).join(" · ") || "(no checks)";
529
+ console.log(`execution lens → ${res.out} — ${summary}` +
530
+ (res.findings.length ? ` — ${res.findings.length} CRITICAL` : ""));
531
+ return res.findings.length ? 1 : 0;
532
+ }
533
+
534
+ // gate subcommand: verify-panel.js gate <phase> <name> --exit <code> [--severity S] [--title T] [--cwd DIR]
535
+ // Deterministic recorder: writes phase-{N}-gate-{name}.json from a gate's REAL
536
+ // exit code, never an LLM hand-writing JSON. Non-zero exit → one finding at the
537
+ // mapped (or --severity-overridden) severity; exit 0 → clean (empty findings).
538
+ // This is how exit-code-only gates (slop-detect, dep-verify) get captured
539
+ // MECHANICALLY into the same artifact verdict folds in.
540
+ if (argv[2] === "gate") {
541
+ const sub = parseArgs(["", "", ...argv.slice(3)]);
542
+ const phase = sub._[0];
543
+ const name = sub._[1];
544
+ if (phase == null || name == null || sub.exit == null) {
545
+ console.error("Usage: verify-panel.js gate <phase> <name> --exit <code> [--severity S] [--title T]");
546
+ return 2;
547
+ }
548
+ const root = path.resolve(sub.cwd || process.cwd());
549
+ let res;
550
+ try {
551
+ res = recordGate(root, phase, name, sub.exit, {
552
+ severity: sub.severity,
553
+ title: sub.title,
554
+ blocking: sub.blocking != null ? sub.blocking !== "false" : null,
555
+ });
556
+ } catch (e) {
557
+ console.error(`ERROR: cannot record gate: ${e.message}`);
558
+ return 2;
559
+ }
560
+ console.log(`gate ${name} → ${res.out} — ${res.failed ? `${res.artifact.severity} (exit ${sub.exit})` : "clean"}`);
561
+ return 0;
562
+ }
563
+
564
+ // verdict subcommand: verify-panel.js verdict <phase> [--json] [--write] [--cwd DIR]
565
+ // THE single deterministic fold. Globs phase-{N}-panel-{lens}.json (existing
566
+ // panel + skeptic survival via assemble/survives) AND phase-{N}-gate-{name}.json
567
+ // (the recorded machine gates) into ONE panel, then runs the SAME aggregate()
568
+ // severity weighting → one PASS/FAIL. Replaces the orchestrator-LLM prose AND.
569
+ // Exit 0 = PASS, 1 = FAIL. Same artifacts → same verdict.
570
+ if (argv[2] === "verdict") {
571
+ const sub = parseArgs(["", "", ...argv.slice(3)]);
572
+ const phase = sub._[0];
573
+ if (phase == null) { usage(); return 2; }
574
+ const root = path.resolve(sub.cwd || process.cwd());
575
+
576
+ // Prefer the skeptic-voted panel.json if the skill already assembled+voted it;
577
+ // otherwise assemble fresh from the per-lens files. Either way the votes the
578
+ // skeptics wrote (writeSkepticVotes) are honored — verdict never re-runs them.
579
+ const panelPath = path.join(root, ".planning", `phase-${phase}-panel.json`);
580
+ let panel;
581
+ try {
582
+ panel = JSON.parse(fs.readFileSync(panelPath, "utf8"));
583
+ } catch {
584
+ panel = assemble(root, phase);
585
+ }
586
+ if (!panel || !Array.isArray(panel.findings)) panel = { phase: Number(phase), lenses: [], findings: [] };
587
+
588
+ const gates = assembleGates(root, phase);
589
+ const merged = {
590
+ phase: panel.phase != null ? panel.phase : Number(phase),
591
+ lenses: [...(panel.lenses || []), ...gates.lenses],
592
+ findings: [...(panel.findings || []), ...gates.findings],
593
+ };
594
+ const result = aggregate(merged);
595
+
596
+ if (sub.write && result.phase != null) {
597
+ const dir = path.join(root, ".planning");
598
+ try {
599
+ fs.mkdirSync(dir, { recursive: true });
600
+ fs.writeFileSync(path.join(dir, `phase-${result.phase}-verdict.json`), JSON.stringify(result, null, 2) + "\n");
601
+ fs.writeFileSync(path.join(dir, `phase-${result.phase}-verdict.md`), toMarkdown(result));
602
+ } catch (e) {
603
+ console.error(`WARN: could not write verdict artifacts: ${e.message}`);
604
+ }
605
+ }
606
+
607
+ if (sub.json) {
608
+ console.log(JSON.stringify(result, null, 2));
609
+ } else {
610
+ console.log(`VERDICT ${result.verdict} phase ${result.phase}: ` +
611
+ `${result.totals.surviving} surviving (${result.counts.CRITICAL}C/${result.counts.HIGH}H/${result.counts.MEDIUM}M/${result.counts.LOW}L), ` +
612
+ `${result.totals.killed} killed, score ${result.score}/5 ` +
613
+ `[${merged.lenses.length} lens/gate inputs]`);
614
+ if (!result.ok) for (const f of result.surviving) {
615
+ if (f.severity === "CRITICAL" || f.severity === "HIGH") {
616
+ console.log(` [${f.severity}] ${f.title} — ${f.file || "?"}${f.line != null ? `:${f.line}` : ""} (${f.lenses.join(", ") || "?"})`);
617
+ }
618
+ }
619
+ }
620
+ return result.ok ? 0 : 1;
621
+ }
622
+
223
623
  // assemble subcommand: verify-panel.js assemble <phase> [--cwd DIR]
224
624
  if (argv[2] === "assemble") {
225
625
  const sub = parseArgs(["", "", ...argv.slice(3)]);
@@ -286,6 +686,15 @@ module.exports = {
286
686
  scoreFromCounts,
287
687
  aggregate,
288
688
  assemble,
689
+ executionChecks,
690
+ runExecution,
691
+ writeExecutionPanel,
692
+ tallySkepticVotes,
693
+ writeSkepticVotes,
694
+ recordGate,
695
+ assembleGates,
696
+ findingKey,
697
+ GATE_DEFAULTS,
289
698
  SEVERITY_WEIGHT,
290
699
  };
291
700
 
@@ -602,7 +602,7 @@
602
602
  </section>
603
603
 
604
604
  <footer>
605
- <span>Qualia Framework v6.2.7 · Plan, build, verify, ship.</span>
605
+ <span>Qualia Framework v7.3.0 · Plan, build, verify, ship.</span>
606
606
  <span><a href="https://github.com/Qualiasolutions/qualia-framework">github.com/Qualiasolutions/qualia-framework</a></span>
607
607
  </footer>
608
608
 
@@ -130,16 +130,30 @@ function notify(event, branch) {
130
130
  }
131
131
 
132
132
  // ── role ────────────────────────────────────────────────────────────────────
133
+ // Role-resolution failures are FAIL-LOUD but non-blocking: a missing config or
134
+ // an unknown role means accountability silently degrades, so we emit a one-line
135
+ // STDERR diagnostic to make it visible — but we never block (this hook must not
136
+ // crash a session on its own error). KNOWN_ROLES is the set we can classify.
137
+ const KNOWN_ROLES = ["OWNER", "EMPLOYEE"];
138
+ function roleUnresolved(reason) {
139
+ console.error(`branch-guard: role unresolved (${reason}) — proceeding unenforced`);
140
+ _trace("allow", { reason: `role unresolved: ${reason}` });
141
+ process.exit(0);
142
+ }
143
+
133
144
  const config = readJson(CONFIG, null);
134
145
  if (!config) {
135
- // Can't classify without config — but the hook no longer blocks anything.
136
- _trace("allow", { reason: "config missing/unreadable" });
137
- process.exit(0);
146
+ // Can't classify without config — visible, but the hook no longer blocks.
147
+ roleUnresolved("config missing/unreadable");
138
148
  }
139
149
  const role = (config.role || "").toUpperCase();
150
+ if (!KNOWN_ROLES.includes(role)) {
151
+ // Empty / unknown role — resolution failed. Surface it, then allow.
152
+ roleUnresolved(role ? `unknown role '${role}'` : "empty role");
153
+ }
140
154
 
141
- // OWNER pushes to main are normal and unremarkable. Anything that isn't a known
142
- // EMPLOYEE is also left alone (nothing to attribute).
155
+ // OWNER pushes to main are normal and unremarkable. A resolved non-EMPLOYEE
156
+ // (OWNER) is left alone silently (nothing to attribute).
143
157
  if (role !== "EMPLOYEE") {
144
158
  _trace("allow", { role });
145
159
  process.exit(0);
@@ -129,9 +129,22 @@ function enqueueErp(config, event) {
129
129
  }
130
130
 
131
131
  try {
132
- const config = readJson(CONFIG, {});
133
- if ((config.role || "").toUpperCase() === "OWNER") process.exit(0);
134
- if ((config.role || "").toUpperCase() !== "EMPLOYEE") process.exit(0);
132
+ // Role-resolution failures are FAIL-LOUD but non-blocking: a missing config or
133
+ // an unknown role means this accountability hook silently goes dark, so emit a
134
+ // one-line STDERR diagnostic to make it visible — but never block (this hook
135
+ // must not crash a session on its own error).
136
+ const config = readJson(CONFIG, null);
137
+ if (!config) {
138
+ console.error("fawzi-approval-guard: role unresolved (config missing/unreadable) — proceeding unenforced");
139
+ process.exit(0);
140
+ }
141
+ const role = (config.role || "").toUpperCase();
142
+ if (role === "OWNER") process.exit(0);
143
+ if (role !== "EMPLOYEE") {
144
+ // Empty / unknown role — resolution failed. Surface it, then allow.
145
+ console.error(`fawzi-approval-guard: role unresolved (${role ? `unknown role '${role}'` : "empty role"}) — proceeding unenforced`);
146
+ process.exit(0);
147
+ }
135
148
 
136
149
  const sample = approvalClaim(hookInputText());
137
150
  if (!sample) process.exit(0);
@@ -0,0 +1,60 @@
1
+ {
2
+ "SessionStart": [
3
+ {
4
+ "matcher": ".*",
5
+ "hooks": [
6
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.js\"", "timeout": 5 }
7
+ ]
8
+ }
9
+ ],
10
+ "PreToolUse": [
11
+ {
12
+ "matcher": "Bash",
13
+ "hooks": [
14
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/auto-update.js\"", "timeout": 5 },
15
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/git-guardrails.js\"", "timeout": 5, "statusMessage": "⬢ Checking git safety..." },
16
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/fawzi-approval-guard.js\"", "timeout": 5 },
17
+ { "type": "command", "if": "Bash(git push*)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/branch-guard.js\"", "timeout": 5, "statusMessage": "⬢ Recording branch activity..." },
18
+ { "type": "command", "if": "Bash(git push*)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-push.js\"", "timeout": 15, "statusMessage": "⬢ Syncing tracking..." },
19
+ { "type": "command", "if": "Bash(vercel --prod*)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-deploy-gate.js\"", "timeout": 600, "statusMessage": "⬢ Running quality gates..." },
20
+ { "type": "command", "if": "Bash(vercel --prod*)|Bash(vercel deploy*)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/vercel-account-guard.js\"", "timeout": 8, "statusMessage": "⬢ Verifying Vercel account..." },
21
+ { "type": "command", "if": "Bash(vercel env*)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/env-empty-guard.js\"", "timeout": 5, "statusMessage": "⬢ Checking env value..." },
22
+ { "type": "command", "if": "Bash(supabase*)|Bash(npx supabase*)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/supabase-destructive-guard.js\"", "timeout": 5, "statusMessage": "⬢ Checking Supabase safety..." },
23
+ { "type": "command", "if": "Bash(*psql*)|Bash(*.sql*)|Bash(supabase db execute*)|Bash(supabase db push*)|Bash(npx supabase db execute*)|Bash(npx supabase db push*)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/migration-guard.js\"", "timeout": 10, "statusMessage": "⬢ Checking migration safety..." },
24
+ { "type": "command", "if": "Bash(git commit*)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard.js\"", "timeout": 10, "statusMessage": "⬢ Scanning staged content for secrets..." }
25
+ ]
26
+ },
27
+ {
28
+ "matcher": "Edit|Write",
29
+ "hooks": [
30
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/fawzi-approval-guard.js\"", "timeout": 5 },
31
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/task-write-guard.js\"", "timeout": 5, "statusMessage": "⬢ Checking plan-contract file scope..." },
32
+ { "type": "command", "if": "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/migration-guard.js\"", "timeout": 10, "statusMessage": "⬢ Checking migration safety..." }
33
+ ]
34
+ }
35
+ ],
36
+ "PreCompact": [
37
+ {
38
+ "matcher": ".*",
39
+ "hooks": [
40
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-compact.js\"", "timeout": 10 }
41
+ ]
42
+ }
43
+ ],
44
+ "Stop": [
45
+ {
46
+ "matcher": ".*",
47
+ "hooks": [
48
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/stop-session-log.js\"", "timeout": 5 }
49
+ ]
50
+ }
51
+ ],
52
+ "UserPromptSubmit": [
53
+ {
54
+ "matcher": ".*",
55
+ "hooks": [
56
+ { "type": "command", "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/usage-capture.js\"", "timeout": 5 }
57
+ ]
58
+ }
59
+ ]
60
+ }