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.
Files changed (42) hide show
  1. package/CLAUDE.md +13 -1
  2. package/README.md +16 -13
  3. package/agents/builder.md +12 -20
  4. package/agents/plan-checker.md +18 -0
  5. package/agents/planner.md +9 -0
  6. package/agents/verifier.md +62 -0
  7. package/bin/agent-runs.js +233 -0
  8. package/bin/cli.js +225 -21
  9. package/bin/install.js +25 -5
  10. package/bin/plan-contract.js +220 -0
  11. package/bin/slop-detect.mjs +357 -0
  12. package/bin/state.js +199 -10
  13. package/docs/agent-runs.md +273 -0
  14. package/docs/erp-contract.md +5 -0
  15. package/docs/plan-contract.md +321 -0
  16. package/hooks/auto-update.js +3 -7
  17. package/hooks/pre-compact.js +22 -11
  18. package/hooks/pre-deploy-gate.js +16 -2
  19. package/hooks/pre-push.js +22 -2
  20. package/hooks/stop-session-log.js +1 -1
  21. package/package.json +8 -2
  22. package/rules/design-brand.md +110 -0
  23. package/rules/design-laws.md +144 -0
  24. package/rules/design-product.md +110 -0
  25. package/rules/design-rubric.md +153 -0
  26. package/skills/qualia-build/SKILL.md +5 -5
  27. package/skills/qualia-flush/SKILL.md +1 -1
  28. package/skills/qualia-new/SKILL.md +40 -3
  29. package/skills/qualia-polish/SKILL.md +180 -136
  30. package/skills/qualia-quick/SKILL.md +1 -1
  31. package/skills/qualia-report/SKILL.md +25 -5
  32. package/skills/qualia-ship/SKILL.md +12 -10
  33. package/skills/zoho-workflow/SKILL.md +64 -0
  34. package/templates/DESIGN.md +229 -435
  35. package/templates/PRODUCT.md +95 -0
  36. package/templates/help.html +13 -7
  37. package/tests/bin.test.sh +6 -3
  38. package/tests/hooks.test.sh +9 -20
  39. package/tests/lib.test.sh +217 -0
  40. package/tests/runner.js +96 -75
  41. package/tests/state.test.sh +4 -3
  42. 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
- // 5 Qualia rules.
165
- const QUALIA_RULE_FILES = ["security.md", "frontend.md", "design-reference.md", "deployment.md", "infrastructure.md"];
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 all 8 hooks are wired (v2 missed block-env-edit and branch-guard)
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 === "branch-guard.js") hookDef.if = "Bash(git push*)";
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.extra && t.extra.verification);
734
- const passes = verifications.filter(t => t.extra.verification === "pass").length;
735
- const fails = verifications.filter(t => t.extra.verification === "fail").length;
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.extra && t.extra.gap_closure);
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
- const erpEnabled = !(cfg.erp && cfg.erp.enabled === false);
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} Migrate settings from v2 to v3`);
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}erp-ping${RESET} Verify ERP connectivity + API key
1017
- qualia-framework ${TEAL}doctor${RESET} Health-check the install (files, hooks, settings)
1018
- qualia-framework ${TEAL}flush${RESET} Promote daily-log curated knowledge (memory layer)`);
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-design${RESET} One-shot design fix`);
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}export QUALIA_ERP_KEY=...${RESET} ${DIM}then re-install,${RESET}`);
590
- log(`${DIM} or write the key to:${RESET} ${WHITE}${erpKeyFile}${RESET} ${DIM}(mode 0600).${RESET}`);
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 no restrictions on env files or branches.
743
- // Everyone can read/write .env, push to main.
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
- console.log(` ${DIM}Rules${RESET} ${TEAL}${ruleCount}${RESET} ${DIM}Scripts${RESET} ${TEAL}3${RESET} ${DIM}Templates${RESET} ${TEAL}${tmplCount}${RESET}`);
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
+ };