qualia-framework 4.3.0 → 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +13 -1
- package/README.md +16 -13
- package/agents/builder.md +12 -20
- package/agents/plan-checker.md +18 -0
- package/agents/planner.md +9 -0
- package/agents/verifier.md +62 -0
- package/bin/agent-runs.js +233 -0
- package/bin/cli.js +225 -21
- package/bin/install.js +25 -5
- package/bin/plan-contract.js +220 -0
- package/bin/slop-detect.mjs +357 -0
- package/bin/state.js +199 -10
- package/docs/agent-runs.md +273 -0
- package/docs/erp-contract.md +5 -0
- package/docs/plan-contract.md +321 -0
- package/hooks/auto-update.js +3 -7
- package/hooks/pre-compact.js +22 -11
- package/hooks/pre-deploy-gate.js +16 -2
- package/hooks/pre-push.js +22 -2
- package/hooks/stop-session-log.js +1 -1
- package/package.json +8 -2
- package/rules/design-brand.md +110 -0
- package/rules/design-laws.md +144 -0
- package/rules/design-product.md +110 -0
- package/rules/design-rubric.md +153 -0
- package/skills/qualia-build/SKILL.md +5 -5
- package/skills/qualia-flush/SKILL.md +1 -1
- package/skills/qualia-new/SKILL.md +40 -3
- package/skills/qualia-polish/SKILL.md +180 -136
- package/skills/qualia-quick/SKILL.md +1 -1
- package/skills/qualia-report/SKILL.md +25 -5
- package/skills/qualia-ship/SKILL.md +12 -10
- package/skills/zoho-workflow/SKILL.md +64 -0
- package/templates/DESIGN.md +229 -435
- package/templates/PRODUCT.md +95 -0
- package/templates/help.html +13 -7
- package/tests/bin.test.sh +6 -3
- package/tests/hooks.test.sh +9 -20
- package/tests/lib.test.sh +217 -0
- package/tests/runner.js +96 -75
- package/tests/state.test.sh +4 -3
- package/skills/qualia-design/SKILL.md +0 -169
package/bin/cli.js
CHANGED
|
@@ -29,7 +29,8 @@ function readConfig() {
|
|
|
29
29
|
|
|
30
30
|
function writeConfig(cfg) {
|
|
31
31
|
if (!fs.existsSync(CLAUDE_DIR)) fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
32
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
|
|
32
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
33
|
+
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch {}
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function banner() {
|
|
@@ -159,10 +160,15 @@ const QUALIA_AGENT_FILES = [
|
|
|
159
160
|
];
|
|
160
161
|
|
|
161
162
|
// 3 Qualia bin scripts.
|
|
162
|
-
const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js", "knowledge.js", "knowledge-flush.js"];
|
|
163
|
-
|
|
164
|
-
//
|
|
165
|
-
|
|
163
|
+
const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js", "knowledge.js", "knowledge-flush.js", "plan-contract.js", "agent-runs.js", "slop-detect.mjs"];
|
|
164
|
+
|
|
165
|
+
// Qualia rules — security, deployment, infra, grounding, plus the v4.5.0 design substrate.
|
|
166
|
+
// frontend.md and design-reference.md are kept for backward compat; new projects use design-laws/brand/product/rubric.
|
|
167
|
+
const QUALIA_RULE_FILES = [
|
|
168
|
+
"security.md", "deployment.md", "infrastructure.md", "grounding.md",
|
|
169
|
+
"frontend.md", "design-reference.md",
|
|
170
|
+
"design-laws.md", "design-brand.md", "design-product.md", "design-rubric.md",
|
|
171
|
+
];
|
|
166
172
|
|
|
167
173
|
function promptYesNo(question, defaultYes) {
|
|
168
174
|
return new Promise((resolve) => {
|
|
@@ -553,7 +559,7 @@ function cmdMigrate() {
|
|
|
553
559
|
console.log(` ${DIM}Target version:${RESET} ${WHITE}${PKG.version}${RESET}`);
|
|
554
560
|
console.log("");
|
|
555
561
|
|
|
556
|
-
// 1. Ensure
|
|
562
|
+
// 1. Ensure the full v4.3 hook set is wired.
|
|
557
563
|
const hd = path.join(CLAUDE_DIR, "hooks");
|
|
558
564
|
const nodeCmd = (hookFile) => `node "${path.join(hd, hookFile)}"`;
|
|
559
565
|
|
|
@@ -564,10 +570,19 @@ function cmdMigrate() {
|
|
|
564
570
|
settings.hooks.SessionStart = [{ matcher: ".*", hooks: [{ type: "command", command: nodeCmd("session-start.js"), timeout: 5 }] }];
|
|
565
571
|
changes++;
|
|
566
572
|
console.log(` ${GREEN}+${RESET} Added SessionStart hook`);
|
|
573
|
+
} else {
|
|
574
|
+
const hasSessionStart = settings.hooks.SessionStart.some(e =>
|
|
575
|
+
Array.isArray(e.hooks) && e.hooks.some(h => typeof h.command === "string" && h.command.includes("session-start.js"))
|
|
576
|
+
);
|
|
577
|
+
if (!hasSessionStart) {
|
|
578
|
+
settings.hooks.SessionStart.push({ matcher: ".*", hooks: [{ type: "command", command: nodeCmd("session-start.js"), timeout: 5 }] });
|
|
579
|
+
changes++;
|
|
580
|
+
console.log(` ${GREEN}+${RESET} Wired session-start.js into SessionStart`);
|
|
581
|
+
}
|
|
567
582
|
}
|
|
568
583
|
|
|
569
584
|
// Check PreToolUse hooks — ensure all critical hooks are present
|
|
570
|
-
const requiredBashHooks = ["auto-update.js", "branch-guard.js", "pre-push.js", "pre-deploy-gate.js"];
|
|
585
|
+
const requiredBashHooks = ["auto-update.js", "git-guardrails.js", "branch-guard.js", "pre-push.js", "pre-deploy-gate.js"];
|
|
571
586
|
const requiredEditHooks = ["migration-guard.js"];
|
|
572
587
|
|
|
573
588
|
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
@@ -595,9 +610,10 @@ function cmdMigrate() {
|
|
|
595
610
|
const exists = bashEntry.hooks.some(h => extractScriptName(h.command) === targetName);
|
|
596
611
|
if (!exists) {
|
|
597
612
|
const hookDef = { type: "command", command: cmd, timeout: hookFile === "pre-deploy-gate.js" ? 180 : 5 };
|
|
598
|
-
if (hookFile === "
|
|
613
|
+
if (hookFile === "git-guardrails.js") hookDef.statusMessage = "⬢ Checking git safety...";
|
|
614
|
+
if (hookFile === "branch-guard.js") { hookDef.if = "Bash(git push*)"; hookDef.statusMessage = "⬢ Checking branch permissions..."; }
|
|
599
615
|
if (hookFile === "pre-push.js") { hookDef.if = "Bash(git push*)"; hookDef.timeout = 15; }
|
|
600
|
-
if (hookFile === "pre-deploy-gate.js") hookDef.if = "Bash(vercel --prod*)";
|
|
616
|
+
if (hookFile === "pre-deploy-gate.js") { hookDef.if = "Bash(vercel --prod*)"; hookDef.timeout = 180; hookDef.statusMessage = "⬢ Running quality gates..."; }
|
|
601
617
|
bashEntry.hooks.push(hookDef);
|
|
602
618
|
changes++;
|
|
603
619
|
console.log(` ${GREEN}+${RESET} Wired ${hookFile} into PreToolUse/Bash`);
|
|
@@ -618,7 +634,7 @@ function cmdMigrate() {
|
|
|
618
634
|
const exists = editEntry.hooks.some(h => extractScriptName(h.command) === targetName);
|
|
619
635
|
if (!exists) {
|
|
620
636
|
const hookDef = { type: "command", command: cmd, timeout: hookFile === "migration-guard.js" ? 10 : 5 };
|
|
621
|
-
if (hookFile === "migration-guard.js") hookDef.if = "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)";
|
|
637
|
+
if (hookFile === "migration-guard.js") { hookDef.if = "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)"; hookDef.statusMessage = "⬢ Checking migration safety..."; }
|
|
622
638
|
editEntry.hooks.push(hookDef);
|
|
623
639
|
changes++;
|
|
624
640
|
console.log(` ${GREEN}+${RESET} Wired ${hookFile} into PreToolUse/Edit|Write`);
|
|
@@ -626,10 +642,42 @@ function cmdMigrate() {
|
|
|
626
642
|
}
|
|
627
643
|
|
|
628
644
|
// Check PreCompact hook
|
|
629
|
-
if (!settings.hooks.PreCompact) {
|
|
645
|
+
if (!settings.hooks.PreCompact || !Array.isArray(settings.hooks.PreCompact)) {
|
|
630
646
|
settings.hooks.PreCompact = [{ matcher: "compact", hooks: [{ type: "command", command: nodeCmd("pre-compact.js"), timeout: 15 }] }];
|
|
631
647
|
changes++;
|
|
632
648
|
console.log(` ${GREEN}+${RESET} Added PreCompact hook`);
|
|
649
|
+
} else {
|
|
650
|
+
let compactEntry = settings.hooks.PreCompact.find(e => e.matcher === "compact");
|
|
651
|
+
if (!compactEntry) {
|
|
652
|
+
compactEntry = { matcher: "compact", hooks: [] };
|
|
653
|
+
settings.hooks.PreCompact.push(compactEntry);
|
|
654
|
+
}
|
|
655
|
+
if (!compactEntry.hooks) compactEntry.hooks = [];
|
|
656
|
+
const exists = compactEntry.hooks.some(h => extractScriptName(h.command) === "pre-compact.js");
|
|
657
|
+
if (!exists) {
|
|
658
|
+
compactEntry.hooks.push({ type: "command", command: nodeCmd("pre-compact.js"), timeout: 15, statusMessage: "⬢ Saving state..." });
|
|
659
|
+
changes++;
|
|
660
|
+
console.log(` ${GREEN}+${RESET} Wired pre-compact.js into PreCompact`);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (!settings.hooks.Stop || !Array.isArray(settings.hooks.Stop)) {
|
|
665
|
+
settings.hooks.Stop = [{ matcher: ".*", hooks: [{ type: "command", command: nodeCmd("stop-session-log.js"), timeout: 5 }] }];
|
|
666
|
+
changes++;
|
|
667
|
+
console.log(` ${GREEN}+${RESET} Added Stop hook`);
|
|
668
|
+
} else {
|
|
669
|
+
let stopEntry = settings.hooks.Stop.find(e => e.matcher === ".*");
|
|
670
|
+
if (!stopEntry) {
|
|
671
|
+
stopEntry = { matcher: ".*", hooks: [] };
|
|
672
|
+
settings.hooks.Stop.push(stopEntry);
|
|
673
|
+
}
|
|
674
|
+
if (!stopEntry.hooks) stopEntry.hooks = [];
|
|
675
|
+
const exists = stopEntry.hooks.some(h => extractScriptName(h.command) === "stop-session-log.js");
|
|
676
|
+
if (!exists) {
|
|
677
|
+
stopEntry.hooks.push({ type: "command", command: nodeCmd("stop-session-log.js"), timeout: 5 });
|
|
678
|
+
changes++;
|
|
679
|
+
console.log(` ${GREEN}+${RESET} Wired stop-session-log.js into Stop`);
|
|
680
|
+
}
|
|
633
681
|
}
|
|
634
682
|
|
|
635
683
|
// 2. Ensure env vars are up to date
|
|
@@ -730,12 +778,12 @@ function cmdAnalytics() {
|
|
|
730
778
|
}
|
|
731
779
|
|
|
732
780
|
// Verification outcomes (from traces that include verification data)
|
|
733
|
-
const verifications = traces.filter(t => t.hook === "state-transition" && t.
|
|
734
|
-
const passes = verifications.filter(t => t.
|
|
735
|
-
const fails = verifications.filter(t => t.
|
|
781
|
+
const verifications = traces.filter(t => t.hook === "state-transition" && t.verification);
|
|
782
|
+
const passes = verifications.filter(t => t.verification === "pass").length;
|
|
783
|
+
const fails = verifications.filter(t => t.verification === "fail").length;
|
|
736
784
|
|
|
737
785
|
// Gap cycle data
|
|
738
|
-
const gapTraces = traces.filter(t => t.hook === "state-transition" && t.
|
|
786
|
+
const gapTraces = traces.filter(t => t.hook === "state-transition" && t.gap_closure);
|
|
739
787
|
const totalGapCycles = gapTraces.length;
|
|
740
788
|
|
|
741
789
|
// Display
|
|
@@ -781,8 +829,9 @@ function cmdErpPing() {
|
|
|
781
829
|
console.log("");
|
|
782
830
|
|
|
783
831
|
const cfg = readConfig();
|
|
832
|
+
const args = new Set(process.argv.slice(3));
|
|
784
833
|
const erpUrl = (cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net";
|
|
785
|
-
|
|
834
|
+
let erpEnabled = !(cfg.erp && cfg.erp.enabled === false);
|
|
786
835
|
const keyFile = path.join(CLAUDE_DIR, ".erp-api-key");
|
|
787
836
|
|
|
788
837
|
console.log(` ${DIM}URL:${RESET} ${WHITE}${erpUrl}${RESET}`);
|
|
@@ -802,6 +851,19 @@ function cmdErpPing() {
|
|
|
802
851
|
console.log(` ${DIM}Key:${RESET} ${GREEN}present${RESET} ${DIM}(${apiKey.length} bytes)${RESET}`);
|
|
803
852
|
console.log("");
|
|
804
853
|
|
|
854
|
+
if (!erpEnabled && args.has("--enable")) {
|
|
855
|
+
cfg.erp = {
|
|
856
|
+
...(cfg.erp || {}),
|
|
857
|
+
enabled: true,
|
|
858
|
+
url: erpUrl,
|
|
859
|
+
api_key_file: ".erp-api-key",
|
|
860
|
+
};
|
|
861
|
+
writeConfig(cfg);
|
|
862
|
+
erpEnabled = true;
|
|
863
|
+
console.log(` ${GREEN}✓ ERP enabled in config.${RESET}`);
|
|
864
|
+
console.log("");
|
|
865
|
+
}
|
|
866
|
+
|
|
805
867
|
if (!erpEnabled) {
|
|
806
868
|
console.log(` ${YELLOW}ERP is disabled in config. Enable with:${RESET}`);
|
|
807
869
|
console.log(` ${DIM} qualia-framework erp-ping --enable${RESET}`);
|
|
@@ -874,6 +936,61 @@ function cmdErpPing() {
|
|
|
874
936
|
process.exit(1);
|
|
875
937
|
}
|
|
876
938
|
|
|
939
|
+
function cmdSetErpKey() {
|
|
940
|
+
banner();
|
|
941
|
+
console.log("");
|
|
942
|
+
|
|
943
|
+
const keyFile = path.join(CLAUDE_DIR, ".erp-api-key");
|
|
944
|
+
const rawArgs = process.argv.slice(3);
|
|
945
|
+
const clear = rawArgs.includes("--clear");
|
|
946
|
+
|
|
947
|
+
if (!fs.existsSync(CLAUDE_DIR)) fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
948
|
+
|
|
949
|
+
if (clear) {
|
|
950
|
+
try { fs.unlinkSync(keyFile); } catch {}
|
|
951
|
+
const cfg = readConfig();
|
|
952
|
+
cfg.erp = { ...(cfg.erp || {}), enabled: false, url: (cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net", api_key_file: ".erp-api-key" };
|
|
953
|
+
writeConfig(cfg);
|
|
954
|
+
console.log(` ${GREEN}✓${RESET} ERP key removed and ERP disabled.`);
|
|
955
|
+
console.log("");
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
let key = rawArgs.find((a) => a && !a.startsWith("--")) || "";
|
|
960
|
+
if (!key && !process.stdin.isTTY) {
|
|
961
|
+
try { key = fs.readFileSync(0, "utf8").trim(); } catch {}
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
key = String(key || "").trim();
|
|
965
|
+
if (!key) {
|
|
966
|
+
console.log(` ${RED}✗${RESET} Missing ERP API key.`);
|
|
967
|
+
console.log(` ${DIM}Usage:${RESET} qualia-framework set-erp-key <key>`);
|
|
968
|
+
console.log(` ${DIM}Safe shell history option:${RESET} printf '%s' "$QUALIA_ERP_KEY" | qualia-framework set-erp-key`);
|
|
969
|
+
console.log("");
|
|
970
|
+
process.exit(1);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (key.length < 10) {
|
|
974
|
+
console.log(` ${YELLOW}!${RESET} Key looks short (${key.length} bytes). Saving anyway.`);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
fs.writeFileSync(keyFile, key, { mode: 0o600 });
|
|
978
|
+
try { fs.chmodSync(keyFile, 0o600); } catch {}
|
|
979
|
+
|
|
980
|
+
const cfg = readConfig();
|
|
981
|
+
cfg.erp = {
|
|
982
|
+
...(cfg.erp || {}),
|
|
983
|
+
enabled: true,
|
|
984
|
+
url: (cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net",
|
|
985
|
+
api_key_file: ".erp-api-key",
|
|
986
|
+
};
|
|
987
|
+
writeConfig(cfg);
|
|
988
|
+
|
|
989
|
+
console.log(` ${GREEN}✓${RESET} ERP key saved to ${WHITE}${keyFile}${RESET}`);
|
|
990
|
+
console.log(` ${DIM}Verify with:${RESET} ${TEAL}qualia-framework erp-ping${RESET}`);
|
|
991
|
+
console.log("");
|
|
992
|
+
}
|
|
993
|
+
|
|
877
994
|
// ─── Doctor: post-install health check ───────────────────
|
|
878
995
|
// Mirrors the spot-check that session-start.js runs once per 24h. Surfaces
|
|
879
996
|
// missing files, mis-wired hooks, stale settings.json, and version drift.
|
|
@@ -1001,6 +1118,84 @@ function cmdDoctor() {
|
|
|
1001
1118
|
process.exit(1);
|
|
1002
1119
|
}
|
|
1003
1120
|
|
|
1121
|
+
// ─── Agents: per-spawn telemetry (see docs/agent-runs.md) ─────────
|
|
1122
|
+
function cmdAgents() {
|
|
1123
|
+
banner();
|
|
1124
|
+
console.log("");
|
|
1125
|
+
|
|
1126
|
+
// Lazy require so the CLI works even if the lib is missing during dev.
|
|
1127
|
+
let lib;
|
|
1128
|
+
try { lib = require("./agent-runs.js"); }
|
|
1129
|
+
catch (e) {
|
|
1130
|
+
console.log(` ${RED}✗${RESET} agent-runs.js not available: ${e.message}`);
|
|
1131
|
+
console.log("");
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
const args = process.argv.slice(3);
|
|
1136
|
+
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
1137
|
+
const sub = args.find((a) => !a.startsWith("--"));
|
|
1138
|
+
|
|
1139
|
+
const cwd = process.cwd();
|
|
1140
|
+
|
|
1141
|
+
if (sub === "prune") {
|
|
1142
|
+
const idx = args.indexOf("--before");
|
|
1143
|
+
const before = idx >= 0 ? args[idx + 1] : null;
|
|
1144
|
+
if (!before) {
|
|
1145
|
+
console.log(` ${RED}✗${RESET} usage: qualia-framework agents prune --before YYYY-MM-DD`);
|
|
1146
|
+
console.log("");
|
|
1147
|
+
process.exit(1);
|
|
1148
|
+
}
|
|
1149
|
+
try {
|
|
1150
|
+
const r = lib.prune(cwd, before);
|
|
1151
|
+
console.log(` ${GREEN}✓${RESET} Pruned ${r.removed} run record(s), ${r.logs_removed} log file(s).`);
|
|
1152
|
+
console.log("");
|
|
1153
|
+
return;
|
|
1154
|
+
} catch (e) {
|
|
1155
|
+
console.log(` ${RED}✗${RESET} ${e.message}`);
|
|
1156
|
+
console.log("");
|
|
1157
|
+
process.exit(1);
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const opts = { limit: 50 };
|
|
1162
|
+
if (flags.has("--failed")) opts.failed = true;
|
|
1163
|
+
const taskIdx = args.indexOf("--task");
|
|
1164
|
+
if (taskIdx >= 0) opts.task_id = args[taskIdx + 1];
|
|
1165
|
+
const phaseIdx = args.indexOf("--phase");
|
|
1166
|
+
if (phaseIdx >= 0) opts.phase = Number(args[phaseIdx + 1]);
|
|
1167
|
+
|
|
1168
|
+
const records = lib.read(cwd, opts);
|
|
1169
|
+
if (records.length === 0) {
|
|
1170
|
+
console.log(` ${DIM}No agent runs recorded yet${RESET} ${DIM}(or run from a project with .planning/)${RESET}`);
|
|
1171
|
+
console.log("");
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
console.log(` ${WHITE}Agent runs${RESET} ${DIM}(showing ${records.length}, newest last)${RESET}`);
|
|
1176
|
+
console.log(` ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
|
|
1177
|
+
console.log(` ${DIM}TIME AGENT PHASE TASK STATUS DURATION NOTE${RESET}`);
|
|
1178
|
+
for (const r of records) {
|
|
1179
|
+
const t = (r.finished_at || r.started_at || "").slice(11, 16);
|
|
1180
|
+
const agent = (r.agent_type || "").padEnd(11);
|
|
1181
|
+
const phase = String(r.phase ?? "-").padEnd(5);
|
|
1182
|
+
const task = (r.task_id || "-").padEnd(4);
|
|
1183
|
+
const stColor = r.status === "success" ? GREEN : r.status === "partial" ? YELLOW : RED;
|
|
1184
|
+
const status = `${stColor}${(r.status || "?").padEnd(9)}${RESET}`;
|
|
1185
|
+
const dur = r.duration_ms != null ? `${Math.round(r.duration_ms / 1000)}s`.padStart(7) : " —";
|
|
1186
|
+
const note = r.failure_reason
|
|
1187
|
+
? `${DIM}${r.failure_reason}${RESET}`
|
|
1188
|
+
: (r.commit_sha ? `${DIM}${r.commit_sha.slice(0, 7)}${RESET}` : "");
|
|
1189
|
+
console.log(` ${t} ${agent} ${phase} ${task} ${status} ${dur} ${note}`);
|
|
1190
|
+
}
|
|
1191
|
+
console.log("");
|
|
1192
|
+
const failed = records.filter((r) => r.status !== "success").length;
|
|
1193
|
+
if (failed > 0) {
|
|
1194
|
+
console.log(` ${YELLOW}${failed} non-success run(s).${RESET} ${DIM}qualia-framework agents --failed for details${RESET}`);
|
|
1195
|
+
console.log("");
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1004
1199
|
function cmdHelp() {
|
|
1005
1200
|
banner();
|
|
1006
1201
|
console.log("");
|
|
@@ -1009,13 +1204,15 @@ function cmdHelp() {
|
|
|
1009
1204
|
console.log(` qualia-framework ${TEAL}update${RESET} Update to the latest version`);
|
|
1010
1205
|
console.log(` qualia-framework ${TEAL}version${RESET} Show installed version + check for updates`);
|
|
1011
1206
|
console.log(` qualia-framework ${TEAL}uninstall${RESET} Clean removal from ~/.claude/ (${DIM}-y to skip prompts${RESET})`);
|
|
1012
|
-
console.log(` qualia-framework ${TEAL}migrate${RESET}
|
|
1207
|
+
console.log(` qualia-framework ${TEAL}migrate${RESET} Wire current hook + env layout into ~/.claude/settings.json`);
|
|
1013
1208
|
console.log(` qualia-framework ${TEAL}team${RESET} Manage team members (${DIM}list|add|remove${RESET})`);
|
|
1014
1209
|
console.log(` qualia-framework ${TEAL}traces${RESET} View recent hook telemetry`);
|
|
1015
1210
|
console.log(` qualia-framework ${TEAL}analytics${RESET} Show outcome scoring & gap cycle stats`);
|
|
1016
|
-
console.log(` qualia-framework ${TEAL}
|
|
1017
|
-
qualia-framework ${TEAL}
|
|
1018
|
-
qualia-framework ${TEAL}
|
|
1211
|
+
console.log(` qualia-framework ${TEAL}agents${RESET} Show per-agent run history (${DIM}--failed|--task ID|--phase N|prune --before${RESET})`);
|
|
1212
|
+
console.log(` qualia-framework ${TEAL}set-erp-key${RESET} Save/enable the ERP API key`);
|
|
1213
|
+
console.log(` qualia-framework ${TEAL}erp-ping${RESET} Verify ERP connectivity + API key`);
|
|
1214
|
+
console.log(` qualia-framework ${TEAL}doctor${RESET} Health-check the install (files, hooks, settings)`);
|
|
1215
|
+
console.log(` qualia-framework ${TEAL}flush${RESET} Promote daily-log → curated knowledge (memory layer)`);
|
|
1019
1216
|
console.log("");
|
|
1020
1217
|
console.log(` ${WHITE}After install:${RESET}`);
|
|
1021
1218
|
console.log(` ${TG}/qualia${RESET} What should I do next?`);
|
|
@@ -1023,7 +1220,7 @@ function cmdHelp() {
|
|
|
1023
1220
|
console.log(` ${TG}/qualia-plan${RESET} Plan a phase`);
|
|
1024
1221
|
console.log(` ${TG}/qualia-build${RESET} Build it (parallel tasks)`);
|
|
1025
1222
|
console.log(` ${TG}/qualia-verify${RESET} Verify it works`);
|
|
1026
|
-
console.log(` ${TG}/qualia-
|
|
1223
|
+
console.log(` ${TG}/qualia-polish${RESET} Design pass — any scope (component, route, app, redesign)`);
|
|
1027
1224
|
console.log(` ${TG}/qualia-debug${RESET} Structured debugging`);
|
|
1028
1225
|
console.log(` ${TG}/qualia-review${RESET} Production audit`);
|
|
1029
1226
|
console.log(` ${TG}/qualia-ship${RESET} Deploy to production`);
|
|
@@ -1064,10 +1261,17 @@ switch (cmd) {
|
|
|
1064
1261
|
case "migrate":
|
|
1065
1262
|
cmdMigrate();
|
|
1066
1263
|
break;
|
|
1264
|
+
case "agents":
|
|
1265
|
+
cmdAgents();
|
|
1266
|
+
break;
|
|
1067
1267
|
case "analytics":
|
|
1068
1268
|
case "stats":
|
|
1069
1269
|
cmdAnalytics();
|
|
1070
1270
|
break;
|
|
1271
|
+
case "set-erp-key":
|
|
1272
|
+
case "erp-key":
|
|
1273
|
+
cmdSetErpKey();
|
|
1274
|
+
break;
|
|
1071
1275
|
case "erp-ping":
|
|
1072
1276
|
case "ping":
|
|
1073
1277
|
cmdErpPing();
|
package/bin/install.js
CHANGED
|
@@ -422,6 +422,22 @@ async function main() {
|
|
|
422
422
|
);
|
|
423
423
|
fs.chmodSync(path.join(binDest, "knowledge-flush.js"), 0o755);
|
|
424
424
|
ok("knowledge-flush.js (cron-runnable flush)");
|
|
425
|
+
copy(
|
|
426
|
+
path.join(FRAMEWORK_DIR, "bin", "plan-contract.js"),
|
|
427
|
+
path.join(binDest, "plan-contract.js")
|
|
428
|
+
);
|
|
429
|
+
ok("plan-contract.js (plan JSON validator)");
|
|
430
|
+
copy(
|
|
431
|
+
path.join(FRAMEWORK_DIR, "bin", "agent-runs.js"),
|
|
432
|
+
path.join(binDest, "agent-runs.js")
|
|
433
|
+
);
|
|
434
|
+
ok("agent-runs.js (agent telemetry writer)");
|
|
435
|
+
copy(
|
|
436
|
+
path.join(FRAMEWORK_DIR, "bin", "slop-detect.mjs"),
|
|
437
|
+
path.join(binDest, "slop-detect.mjs")
|
|
438
|
+
);
|
|
439
|
+
fs.chmodSync(path.join(binDest, "slop-detect.mjs"), 0o755);
|
|
440
|
+
ok("slop-detect.mjs (anti-pattern scanner — runs pre-commit on frontend builds)");
|
|
425
441
|
} catch (e) {
|
|
426
442
|
warn(`scripts — ${e.message}`);
|
|
427
443
|
}
|
|
@@ -586,8 +602,8 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
586
602
|
try { fs.chmodSync(configFile, 0o600); } catch {}
|
|
587
603
|
} catch {}
|
|
588
604
|
log(`${YELLOW}!${RESET} ERP key not configured — reports won't upload until set.`);
|
|
589
|
-
log(`${DIM} Set with:${RESET} ${TEAL}
|
|
590
|
-
log(`${DIM} or
|
|
605
|
+
log(`${DIM} Set with:${RESET} ${TEAL}qualia-framework set-erp-key <key>${RESET}`);
|
|
606
|
+
log(`${DIM} or:${RESET} ${TEAL}export QUALIA_ERP_KEY=...${RESET} ${DIM}then re-install.${RESET}`);
|
|
591
607
|
log(`${DIM} Get a key from Fawzi.${RESET}`);
|
|
592
608
|
}
|
|
593
609
|
|
|
@@ -739,8 +755,8 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
739
755
|
settings.hooks[event] = [...cleaned, ...qualiaHooks[event]];
|
|
740
756
|
}
|
|
741
757
|
|
|
742
|
-
// Permissions
|
|
743
|
-
//
|
|
758
|
+
// Permissions stay permissive; Qualia policy enforcement happens in hooks so
|
|
759
|
+
// OWNER overrides and EMPLOYEE blocks can share one source of truth.
|
|
744
760
|
if (!settings.permissions) settings.permissions = {};
|
|
745
761
|
if (!settings.permissions.allow) settings.permissions.allow = [];
|
|
746
762
|
if (!settings.permissions.deny) settings.permissions.deny = [];
|
|
@@ -776,7 +792,11 @@ Client-specific preferences, design choices, and requirements. Loaded by \`/qual
|
|
|
776
792
|
const ruleCount = fs.readdirSync(rulesDir).length;
|
|
777
793
|
const tmplCount = fs.readdirSync(tmplDir).length;
|
|
778
794
|
console.log(` ${DIM}Skills${RESET} ${TEAL}${skills.length}${RESET} ${DIM}Agents${RESET} ${TEAL}${agentCount}${RESET} ${DIM}Hooks${RESET} ${TEAL}${hookCount}${RESET}`);
|
|
779
|
-
|
|
795
|
+
const installedBinDir = path.join(CLAUDE_DIR, "bin");
|
|
796
|
+
const scriptCount = fs.existsSync(installedBinDir)
|
|
797
|
+
? fs.readdirSync(installedBinDir).filter(f => f.endsWith('.js')).length
|
|
798
|
+
: 0;
|
|
799
|
+
console.log(` ${DIM}Rules${RESET} ${TEAL}${ruleCount}${RESET} ${DIM}Scripts${RESET} ${TEAL}${scriptCount}${RESET} ${DIM}Templates${RESET} ${TEAL}${tmplCount}${RESET}`);
|
|
780
800
|
|
|
781
801
|
if (errors > 0) {
|
|
782
802
|
console.log("");
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Plan contract validator + helpers. See docs/plan-contract.md.
|
|
3
|
+
//
|
|
4
|
+
// Pure library — no CLI dispatch. Required by state.js and by skills that
|
|
5
|
+
// emit/consume `.planning/phase-{N}-contract.json`.
|
|
6
|
+
//
|
|
7
|
+
// Zero npm dependencies. Hand-rolled validator, ~100 LOC.
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
|
|
13
|
+
const SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
const PERSONAS = new Set([
|
|
16
|
+
"security", "architect", "ux", "frontend",
|
|
17
|
+
"backend", "data", "performance", "none",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const CHECK_TYPES = new Set([
|
|
21
|
+
"file-exists", "grep-match", "command-exit", "behavioral",
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
function isStringArray(v) {
|
|
25
|
+
return Array.isArray(v) && v.every((x) => typeof x === "string");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isPlainObject(v) {
|
|
29
|
+
return v && typeof v === "object" && !Array.isArray(v);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateCheck(check, taskId, idx) {
|
|
33
|
+
const errs = [];
|
|
34
|
+
const where = `tasks[id=${taskId}].verification[${idx}]`;
|
|
35
|
+
if (!isPlainObject(check)) return [`${where}: not an object`];
|
|
36
|
+
if (!CHECK_TYPES.has(check.type)) {
|
|
37
|
+
errs.push(`${where}.type: must be one of ${[...CHECK_TYPES].join("|")}`);
|
|
38
|
+
return errs;
|
|
39
|
+
}
|
|
40
|
+
switch (check.type) {
|
|
41
|
+
case "file-exists":
|
|
42
|
+
if (typeof check.path !== "string" || !check.path) errs.push(`${where}.path: required string`);
|
|
43
|
+
if (check.must_contain != null && typeof check.must_contain !== "string") errs.push(`${where}.must_contain: must be string`);
|
|
44
|
+
break;
|
|
45
|
+
case "grep-match":
|
|
46
|
+
if (typeof check.path !== "string" || !check.path) errs.push(`${where}.path: required string`);
|
|
47
|
+
if (typeof check.pattern !== "string" || !check.pattern) errs.push(`${where}.pattern: required string`);
|
|
48
|
+
else { try { new RegExp(check.pattern); } catch { errs.push(`${where}.pattern: invalid regex`); } }
|
|
49
|
+
if (check.expect !== "present" && check.expect !== "absent") errs.push(`${where}.expect: must be "present" or "absent"`);
|
|
50
|
+
break;
|
|
51
|
+
case "command-exit":
|
|
52
|
+
if (typeof check.command !== "string" || !check.command) errs.push(`${where}.command: required string`);
|
|
53
|
+
else if (/[;&|`$<>(){}\\]/.test(check.command)) errs.push(`${where}.command: shell metacharacters not allowed (use args[])`);
|
|
54
|
+
if (!isStringArray(check.args || [])) errs.push(`${where}.args: must be string[]`);
|
|
55
|
+
if (typeof check.expected_exit !== "number") errs.push(`${where}.expected_exit: required number`);
|
|
56
|
+
if (check.timeout_ms != null && (typeof check.timeout_ms !== "number" || check.timeout_ms <= 0)) {
|
|
57
|
+
errs.push(`${where}.timeout_ms: must be positive number`);
|
|
58
|
+
}
|
|
59
|
+
if (check.expect_stdout_match != null) {
|
|
60
|
+
if (typeof check.expect_stdout_match !== "string") errs.push(`${where}.expect_stdout_match: must be string`);
|
|
61
|
+
else { try { new RegExp(check.expect_stdout_match); } catch { errs.push(`${where}.expect_stdout_match: invalid regex`); } }
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
case "behavioral":
|
|
65
|
+
if (typeof check.description !== "string" || !check.description) errs.push(`${where}.description: required string`);
|
|
66
|
+
if (!Array.isArray(check.evidence_required) || check.evidence_required.length === 0) {
|
|
67
|
+
errs.push(`${where}.evidence_required: must be a non-empty array`);
|
|
68
|
+
} else {
|
|
69
|
+
check.evidence_required.forEach((ev, i) => {
|
|
70
|
+
const w = `${where}.evidence_required[${i}]`;
|
|
71
|
+
if (!isPlainObject(ev)) { errs.push(`${w}: not an object`); return; }
|
|
72
|
+
if (typeof ev.path !== "string" || !ev.path) errs.push(`${w}.path: required string`);
|
|
73
|
+
if (typeof ev.description !== "string" || !ev.description) errs.push(`${w}.description: required string`);
|
|
74
|
+
if (ev.matcher != null) {
|
|
75
|
+
if (typeof ev.matcher !== "string") errs.push(`${w}.matcher: must be string`);
|
|
76
|
+
else { try { new RegExp(ev.matcher); } catch { errs.push(`${w}.matcher: invalid regex`); } }
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
return errs;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function validateTask(task, idx, allIds) {
|
|
86
|
+
const errs = [];
|
|
87
|
+
const where = `tasks[${idx}]`;
|
|
88
|
+
if (!isPlainObject(task)) return [`${where}: not an object`];
|
|
89
|
+
if (typeof task.id !== "string" || !/^T\d+$/.test(task.id)) errs.push(`${where}.id: must match ^T\\d+$`);
|
|
90
|
+
if (typeof task.title !== "string" || !task.title) errs.push(`${where}.title: required string`);
|
|
91
|
+
if (typeof task.wave !== "number" || task.wave < 1) errs.push(`${where}.wave: must be positive number`);
|
|
92
|
+
if (!isStringArray(task.depends_on || [])) errs.push(`${where}.depends_on: must be string[]`);
|
|
93
|
+
if (task.persona != null && !PERSONAS.has(task.persona)) errs.push(`${where}.persona: invalid value`);
|
|
94
|
+
if (!isStringArray(task.files_modify || [])) errs.push(`${where}.files_modify: must be string[]`);
|
|
95
|
+
if (!isStringArray(task.files_create || [])) errs.push(`${where}.files_create: must be string[]`);
|
|
96
|
+
if (!isStringArray(task.files_delete || [])) errs.push(`${where}.files_delete: must be string[]`);
|
|
97
|
+
if (!isStringArray(task.acceptance_criteria || []) || (task.acceptance_criteria || []).length === 0) {
|
|
98
|
+
errs.push(`${where}.acceptance_criteria: must be a non-empty string[]`);
|
|
99
|
+
}
|
|
100
|
+
if (typeof task.action !== "string") errs.push(`${where}.action: required string`);
|
|
101
|
+
else if (task.action.length > 500) errs.push(`${where}.action: must be ≤ 500 characters (got ${task.action.length})`);
|
|
102
|
+
if (!isStringArray(task.context_files || [])) errs.push(`${where}.context_files: must be string[]`);
|
|
103
|
+
if (!Array.isArray(task.verification) || task.verification.length === 0) {
|
|
104
|
+
errs.push(`${where}.verification: must be a non-empty array`);
|
|
105
|
+
} else {
|
|
106
|
+
task.verification.forEach((c, i) => errs.push(...validateCheck(c, task.id, i)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// disjointness across files_modify/create/delete
|
|
110
|
+
const m = new Set(task.files_modify || []);
|
|
111
|
+
const c = new Set(task.files_create || []);
|
|
112
|
+
const d = new Set(task.files_delete || []);
|
|
113
|
+
for (const p of m) if (c.has(p) || d.has(p)) errs.push(`${where}: ${p} appears in multiple file lists`);
|
|
114
|
+
for (const p of c) if (d.has(p)) errs.push(`${where}: ${p} appears in multiple file lists`);
|
|
115
|
+
|
|
116
|
+
// depends_on references must exist
|
|
117
|
+
for (const dep of task.depends_on || []) {
|
|
118
|
+
if (!allIds.has(dep)) errs.push(`${where}.depends_on: references unknown id "${dep}"`);
|
|
119
|
+
}
|
|
120
|
+
return errs;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function detectCycles(tasks) {
|
|
124
|
+
const graph = new Map(tasks.map((t) => [t.id, t.depends_on || []]));
|
|
125
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
126
|
+
const color = new Map([...graph.keys()].map((k) => [k, WHITE]));
|
|
127
|
+
const cycles = [];
|
|
128
|
+
function dfs(u, stack) {
|
|
129
|
+
color.set(u, GRAY);
|
|
130
|
+
stack.push(u);
|
|
131
|
+
for (const v of graph.get(u) || []) {
|
|
132
|
+
if (!graph.has(v)) continue;
|
|
133
|
+
const cv = color.get(v);
|
|
134
|
+
if (cv === GRAY) { cycles.push([...stack, v].join(" → ")); return; }
|
|
135
|
+
if (cv === WHITE) dfs(v, stack);
|
|
136
|
+
}
|
|
137
|
+
color.set(u, BLACK);
|
|
138
|
+
stack.pop();
|
|
139
|
+
}
|
|
140
|
+
for (const k of graph.keys()) if (color.get(k) === WHITE) dfs(k, []);
|
|
141
|
+
return cycles;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function validate(contract) {
|
|
145
|
+
const errs = [];
|
|
146
|
+
if (!isPlainObject(contract)) return ["contract: not an object"];
|
|
147
|
+
if (contract.version !== SCHEMA_VERSION) errs.push(`version: must be ${SCHEMA_VERSION}, got ${contract.version}`);
|
|
148
|
+
if (typeof contract.phase !== "number" || contract.phase < 1) errs.push("phase: must be positive number");
|
|
149
|
+
if (typeof contract.goal !== "string" || !contract.goal) errs.push("goal: required string");
|
|
150
|
+
if (typeof contract.why !== "string" || !contract.why) errs.push("why: required string");
|
|
151
|
+
if (typeof contract.generated_at !== "string") errs.push("generated_at: required ISO 8601 string");
|
|
152
|
+
if (!["planner", "compile-plan", "manual"].includes(contract.generated_by)) {
|
|
153
|
+
errs.push('generated_by: must be "planner" | "compile-plan" | "manual"');
|
|
154
|
+
}
|
|
155
|
+
if (typeof contract.source_plan_hash !== "string") errs.push("source_plan_hash: required string (empty for manual)");
|
|
156
|
+
if (!isStringArray(contract.success_criteria || []) || (contract.success_criteria || []).length === 0) {
|
|
157
|
+
errs.push("success_criteria: must be a non-empty string[]");
|
|
158
|
+
}
|
|
159
|
+
if (!Array.isArray(contract.tasks) || contract.tasks.length === 0) {
|
|
160
|
+
errs.push("tasks: must be a non-empty array");
|
|
161
|
+
return errs;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const ids = new Set();
|
|
165
|
+
for (const t of contract.tasks) {
|
|
166
|
+
if (t && typeof t.id === "string") {
|
|
167
|
+
if (ids.has(t.id)) errs.push(`tasks: duplicate id "${t.id}"`);
|
|
168
|
+
ids.add(t.id);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
contract.tasks.forEach((t, i) => errs.push(...validateTask(t, i, ids)));
|
|
173
|
+
|
|
174
|
+
const cycles = detectCycles(contract.tasks);
|
|
175
|
+
if (cycles.length) errs.push(`tasks: cycle detected: ${cycles[0]}`);
|
|
176
|
+
|
|
177
|
+
// wave > max(deps wave)
|
|
178
|
+
const byId = new Map(contract.tasks.map((t) => [t.id, t]));
|
|
179
|
+
for (const t of contract.tasks) {
|
|
180
|
+
const deps = (t.depends_on || []).map((id) => byId.get(id)).filter(Boolean);
|
|
181
|
+
if (deps.length === 0) continue;
|
|
182
|
+
const maxDepWave = Math.max(...deps.map((d) => d.wave || 0));
|
|
183
|
+
if ((t.wave || 0) <= maxDepWave) {
|
|
184
|
+
errs.push(`tasks[id=${t.id}].wave: must be > ${maxDepWave} (max wave of its dependencies)`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return errs;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function parseSafely(text) {
|
|
192
|
+
try {
|
|
193
|
+
return { ok: true, value: JSON.parse(text) };
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { ok: false, error: e.message };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function hashPlan(text) {
|
|
200
|
+
return "sha256:" + crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function checkDrift(contractPath, planMdPath) {
|
|
204
|
+
if (!fs.existsSync(contractPath)) return { ok: false, reason: "contract-missing" };
|
|
205
|
+
if (!fs.existsSync(planMdPath)) return { ok: false, reason: "plan-md-missing" };
|
|
206
|
+
const parsed = parseSafely(fs.readFileSync(contractPath, "utf8"));
|
|
207
|
+
if (!parsed.ok) return { ok: false, reason: "contract-unparseable", detail: parsed.error };
|
|
208
|
+
const stored = parsed.value.source_plan_hash;
|
|
209
|
+
if (!stored) return { ok: true, drift: false, reason: "no-hash-stored" };
|
|
210
|
+
const current = hashPlan(fs.readFileSync(planMdPath, "utf8"));
|
|
211
|
+
return { ok: true, drift: stored !== current, stored, current };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
module.exports = {
|
|
215
|
+
SCHEMA_VERSION,
|
|
216
|
+
validate,
|
|
217
|
+
parseSafely,
|
|
218
|
+
hashPlan,
|
|
219
|
+
checkDrift,
|
|
220
|
+
};
|