qualia-framework 7.2.2 → 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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +17 -0
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +42 -0
- package/CLAUDE.md +1 -1
- package/README.md +17 -4
- package/TROUBLESHOOTING.md +8 -7
- package/agents/verifier.md +1 -1
- package/bin/agent-status.js +115 -11
- package/bin/auto-report.js +15 -7
- package/bin/cli.js +173 -4
- package/bin/erp-retry.js +92 -8
- package/bin/install.js +102 -2
- package/bin/qualia-doctor.js +115 -1
- package/bin/state.js +102 -13
- package/bin/verify-panel.js +409 -0
- package/docs/onboarding.html +1 -1
- package/hooks/branch-guard.js +19 -5
- package/hooks/fawzi-approval-guard.js +16 -3
- package/hooks/hooks.json +60 -0
- package/hooks/migration-guard.js +143 -66
- package/hooks/session-start.js +27 -0
- package/package.json +3 -1
- package/skills/qualia/SKILL.md +20 -13
- package/skills/qualia-build/SKILL.md +20 -9
- package/skills/qualia-verify/SKILL.md +43 -5
- package/templates/instructions.md +2 -2
- package/tests/bin.test.sh +183 -0
- package/tests/hooks.test.sh +124 -0
- package/tests/install-smoke.test.sh +14 -0
- package/tests/instructions.test.sh +2 -2
- package/tests/lib.test.sh +149 -0
- package/tests/plugin-manifest.test.sh +168 -0
- package/tests/refs.test.sh +64 -0
- package/tests/run-all.sh +1 -0
- package/tests/state.test.sh +174 -0
- package/tests/verify-panel.test.sh +236 -0
package/bin/verify-panel.js
CHANGED
|
@@ -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
|
|
package/docs/onboarding.html
CHANGED
|
@@ -602,7 +602,7 @@
|
|
|
602
602
|
</section>
|
|
603
603
|
|
|
604
604
|
<footer>
|
|
605
|
-
<span>Qualia Framework
|
|
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
|
|
package/hooks/branch-guard.js
CHANGED
|
@@ -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
|
|
136
|
-
|
|
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.
|
|
142
|
-
//
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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);
|
package/hooks/hooks.json
ADDED
|
@@ -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
|
+
}
|