qualia-framework 5.9.1 → 6.2.7

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 (81) hide show
  1. package/AGENTS.md +2 -1
  2. package/CLAUDE.md +2 -1
  3. package/README.md +45 -29
  4. package/agents/builder.md +1 -5
  5. package/agents/plan-checker.md +1 -1
  6. package/agents/planner.md +2 -6
  7. package/agents/qa-browser.md +3 -3
  8. package/agents/roadmapper.md +2 -2
  9. package/agents/verifier.md +7 -9
  10. package/agents/visual-evaluator.md +1 -3
  11. package/bin/cli.js +370 -205
  12. package/bin/erp-retry.js +11 -3
  13. package/bin/install.js +383 -55
  14. package/bin/knowledge-flush.js +25 -13
  15. package/bin/knowledge.js +11 -1
  16. package/bin/project-snapshot.js +293 -0
  17. package/bin/qualia-ui.js +13 -2
  18. package/bin/report-payload.js +137 -0
  19. package/bin/slop-detect.mjs +81 -9
  20. package/bin/state.js +8 -1
  21. package/bin/statusline.js +14 -2
  22. package/docs/archive/CHANGELOG-pre-v4.md +855 -0
  23. package/docs/changelog-v6.html +864 -0
  24. package/docs/ecosystem-operating-model.md +121 -0
  25. package/docs/erp-contract.md +74 -21
  26. package/docs/onboarding.html +2 -2
  27. package/docs/release.md +44 -0
  28. package/docs/reviews/v6.2.1-revival-audit.md +53 -0
  29. package/docs/reviews/v6.2.2-memory-erp-audit.md +41 -0
  30. package/docs/reviews/v6.2.3-erp-id-guard.md +15 -0
  31. package/guide.md +28 -3
  32. package/hooks/auto-update.js +20 -10
  33. package/hooks/branch-guard.js +10 -2
  34. package/hooks/env-empty-guard.js +15 -5
  35. package/hooks/git-guardrails.js +10 -1
  36. package/hooks/migration-guard.js +4 -1
  37. package/hooks/pre-deploy-gate.js +11 -1
  38. package/hooks/pre-push.js +43 -106
  39. package/hooks/session-start.js +22 -14
  40. package/hooks/stop-session-log.js +11 -3
  41. package/hooks/supabase-destructive-guard.js +11 -1
  42. package/hooks/vercel-account-guard.js +12 -3
  43. package/package.json +4 -3
  44. package/qualia-design/design-reference.md +2 -1
  45. package/qualia-design/frontend.md +4 -4
  46. package/rules/one-opinion.md +59 -0
  47. package/rules/trust-boundary.md +35 -0
  48. package/skills/qualia-feature/SKILL.md +5 -5
  49. package/skills/qualia-flush/SKILL.md +5 -7
  50. package/skills/qualia-hook-gen/SKILL.md +1 -1
  51. package/skills/qualia-learn/SKILL.md +1 -0
  52. package/skills/qualia-map/SKILL.md +2 -1
  53. package/skills/qualia-milestone/SKILL.md +2 -2
  54. package/skills/qualia-new/SKILL.md +6 -6
  55. package/skills/qualia-optimize/SKILL.md +1 -1
  56. package/skills/qualia-plan/SKILL.md +1 -1
  57. package/skills/qualia-polish/REFERENCE.md +8 -6
  58. package/skills/qualia-polish/SKILL.md +11 -9
  59. package/skills/qualia-polish/scripts/loop.mjs +18 -6
  60. package/skills/qualia-postmortem/SKILL.md +1 -1
  61. package/skills/qualia-report/SKILL.md +6 -42
  62. package/skills/qualia-road/SKILL.md +17 -5
  63. package/skills/qualia-verify/SKILL.md +3 -3
  64. package/skills/qualia-vibe/SKILL.md +226 -0
  65. package/skills/qualia-vibe/scripts/extract.mjs +141 -0
  66. package/skills/qualia-vibe/scripts/tokens.mjs +342 -0
  67. package/templates/help.html +10 -3
  68. package/templates/knowledge/agents.md +3 -3
  69. package/templates/knowledge/index.md +1 -1
  70. package/templates/tracking.json +3 -0
  71. package/templates/work-packet.md +46 -0
  72. package/tests/bin.test.sh +423 -25
  73. package/tests/hooks.test.sh +1 -8
  74. package/tests/install-smoke.test.sh +137 -0
  75. package/tests/published-install-smoke.test.sh +126 -0
  76. package/tests/refs.test.sh +43 -1
  77. package/tests/run-all.sh +49 -0
  78. package/tests/runner.js +19 -33
  79. package/tests/slop-detect.test.sh +11 -5
  80. package/tests/state.test.sh +4 -1
  81. package/hooks/pre-compact.js +0 -125
package/bin/knowledge.js CHANGED
@@ -28,7 +28,17 @@ const fs = require("fs");
28
28
  const path = require("path");
29
29
  const os = require("os");
30
30
 
31
- const KNOWLEDGE_DIR = path.join(os.homedir(), ".claude", "knowledge");
31
+ function qualiaHome() {
32
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
33
+ const parent = path.basename(path.dirname(__dirname));
34
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
35
+ const claude = path.join(os.homedir(), ".claude");
36
+ if (fs.existsSync(path.join(claude, "knowledge"))) return claude;
37
+ return claude;
38
+ }
39
+
40
+ const QUALIA_HOME = qualiaHome();
41
+ const KNOWLEDGE_DIR = path.join(QUALIA_HOME, "knowledge");
32
42
  const INDEX_FILE = path.join(KNOWLEDGE_DIR, "index.md");
33
43
 
34
44
  // Type → filename mapping for `append` and convenience aliases used by the
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const http = require("http");
4
+ const https = require("https");
5
+ const os = require("os");
6
+ const path = require("path");
7
+ const { spawnSync } = require("child_process");
8
+
9
+ function readJson(file, fallback = {}) {
10
+ try {
11
+ return JSON.parse(fs.readFileSync(file, "utf8"));
12
+ } catch {
13
+ return fallback;
14
+ }
15
+ }
16
+
17
+ function readText(file, fallback = "") {
18
+ try {
19
+ return fs.readFileSync(file, "utf8");
20
+ } catch {
21
+ return fallback;
22
+ }
23
+ }
24
+
25
+ function qualiaHome(home = os.homedir()) {
26
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
27
+ const parent = path.basename(path.dirname(__dirname));
28
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
29
+ const claude = path.join(home, ".claude");
30
+ if (fs.existsSync(path.join(claude, ".qualia-config.json"))) return claude;
31
+ return claude;
32
+ }
33
+
34
+ function git(args, cwd) {
35
+ const result = spawnSync("git", args, {
36
+ cwd,
37
+ encoding: "utf8",
38
+ timeout: 3000,
39
+ });
40
+ return result.status === 0 ? result.stdout.trim() : "";
41
+ }
42
+
43
+ function repoSlug(remote) {
44
+ return (remote || "")
45
+ .replace(/^git@github\.com:/, "github.com/")
46
+ .replace(/^https?:\/\//, "")
47
+ .replace(/\.git$/, "")
48
+ .split("/")
49
+ .filter(Boolean)
50
+ .pop();
51
+ }
52
+
53
+ function uuid(value) {
54
+ if (typeof value !== "string") return "";
55
+ const trimmed = value.trim();
56
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)
57
+ ? trimmed
58
+ : "";
59
+ }
60
+
61
+ function parseJourneyMilestones(content) {
62
+ if (!content) return [];
63
+ const milestones = [];
64
+ const re = /^##\s+Milestone\s+(\d+)\s*[·•-]\s*(.+?)\s*(?:\[[^\]]+\])?\s*$/gm;
65
+ let match;
66
+ while ((match = re.exec(content)) !== null) {
67
+ milestones.push({
68
+ num: Number(match[1]),
69
+ name: match[2].trim(),
70
+ });
71
+ }
72
+ return milestones;
73
+ }
74
+
75
+ function clampPercent(value) {
76
+ if (!Number.isFinite(value)) return 0;
77
+ return Math.max(0, Math.min(100, Math.round(value)));
78
+ }
79
+
80
+ function buildSnapshot(options = {}) {
81
+ const cwd = options.cwd || process.cwd();
82
+ const home = options.home || os.homedir();
83
+ const installHome = options.qualiaHome || qualiaHome(home);
84
+ const now = options.now || new Date().toISOString();
85
+ const planning = options.planningDir || path.join(cwd, ".planning");
86
+ const tracking = readJson(path.join(planning, "tracking.json"), {});
87
+ const config = readJson(path.join(installHome, ".qualia-config.json"), {});
88
+ const journey = parseJourneyMilestones(readText(path.join(planning, "JOURNEY.md"), ""));
89
+ const gitRemote = tracking.git_remote || git(["config", "--get", "remote.origin.url"], cwd);
90
+ const projectId = tracking.project_id || repoSlug(gitRemote) || path.basename(cwd);
91
+ const currentMilestone = Number(tracking.milestone || 1);
92
+ const currentPhase = Number(tracking.phase || 0);
93
+ const totalPhases = Number(tracking.total_phases || 0);
94
+ const lifetime = tracking.lifetime && typeof tracking.lifetime === "object" ? tracking.lifetime : {};
95
+ const closedMilestones = Array.isArray(tracking.milestones) ? tracking.milestones : [];
96
+ const closedMilestoneNums = new Set(
97
+ closedMilestones
98
+ .map((m) => Number(m && m.num))
99
+ .filter((n) => Number.isFinite(n))
100
+ );
101
+ const journeyTotal = journey.length || Math.max(currentMilestone, closedMilestones.length || 1);
102
+ const phaseProgress =
103
+ totalPhases > 0 && currentPhase > 0 ? (Math.max(0, currentPhase - 1) / totalPhases) : 0;
104
+ const projectProgress =
105
+ journeyTotal > 0
106
+ ? ((closedMilestoneNums.size + Math.min(1, phaseProgress)) / journeyTotal) * 100
107
+ : 0;
108
+
109
+ return {
110
+ snapshot_version: 1,
111
+ generated_at: now,
112
+ source: "qualia-framework",
113
+ framework_version: config.version || "",
114
+ identifiers: {
115
+ project_id: projectId,
116
+ team_id: tracking.team_id || "qualia-solutions",
117
+ git_remote: gitRemote || "",
118
+ ...(uuid(tracking.erp_project_id) ? { erp_project_id: uuid(tracking.erp_project_id) } : {}),
119
+ ...(uuid(tracking.client_id) ? { client_id: uuid(tracking.client_id) } : {}),
120
+ ...(uuid(tracking.workspace_id) ? { workspace_id: uuid(tracking.workspace_id) } : {}),
121
+ },
122
+ project: {
123
+ name: tracking.project || path.basename(cwd),
124
+ client: tracking.client || "",
125
+ status: tracking.status || "setup",
126
+ deployed_url: tracking.deployed_url || "",
127
+ progress_percent: clampPercent(projectProgress),
128
+ },
129
+ current: {
130
+ milestone: currentMilestone,
131
+ milestone_name: tracking.milestone_name || "",
132
+ phase: currentPhase,
133
+ phase_name: tracking.phase_name || "",
134
+ total_phases: totalPhases,
135
+ tasks_done: Number(tracking.tasks_done || 0),
136
+ tasks_total: Number(tracking.tasks_total || 0),
137
+ verification: tracking.verification || "pending",
138
+ gap_cycles: (tracking.gap_cycles || {})[String(currentPhase)] || 0,
139
+ },
140
+ journey: {
141
+ total_milestones: journeyTotal,
142
+ milestones: journey.map((milestone) => ({
143
+ ...milestone,
144
+ status:
145
+ closedMilestoneNums.has(milestone.num)
146
+ ? "closed"
147
+ : milestone.num === currentMilestone
148
+ ? "active"
149
+ : "pending",
150
+ })),
151
+ closed_milestones: closedMilestones,
152
+ },
153
+ lifetime: {
154
+ tasks_completed: Number(lifetime.tasks_completed || 0),
155
+ phases_completed: Number(lifetime.phases_completed || 0),
156
+ milestones_completed: Number(lifetime.milestones_completed || closedMilestoneNums.size || 0),
157
+ total_phases: Number(lifetime.total_phases || 0),
158
+ last_closed_milestone: Number(lifetime.last_closed_milestone || 0),
159
+ build_count: Number(tracking.build_count || 0),
160
+ deploy_count: Number(tracking.deploy_count || 0),
161
+ },
162
+ timestamps: {
163
+ session_started_at: tracking.session_started_at || "",
164
+ last_pushed_at: tracking.last_pushed_at || "",
165
+ last_updated: tracking.last_updated || "",
166
+ },
167
+ };
168
+ }
169
+
170
+ function writeSnapshot(snapshot, options = {}) {
171
+ const cwd = options.cwd || process.cwd();
172
+ const planning = options.planningDir || path.join(cwd, ".planning");
173
+ const outDir = options.outDir || path.join(planning, "snapshots");
174
+ fs.mkdirSync(outDir, { recursive: true });
175
+ const stamp = snapshot.generated_at.replace(/[:.]/g, "-");
176
+ const file = path.join(outDir, `project-snapshot-${stamp}.json`);
177
+ fs.writeFileSync(file, `${JSON.stringify(snapshot, null, 2)}\n`);
178
+ return file;
179
+ }
180
+
181
+ function erpConfig(options = {}) {
182
+ const home = options.home || os.homedir();
183
+ const installHome = options.qualiaHome || qualiaHome(home);
184
+ const config = readJson(path.join(installHome, ".qualia-config.json"), {});
185
+ const erp = config.erp || {};
186
+ const url = (erp.url || "https://portal.qualiasolutions.net").replace(/\/+$/, "");
187
+ const keyFile = path.join(installHome, erp.api_key_file || ".erp-api-key");
188
+ const key = readText(keyFile, "").trim();
189
+ return {
190
+ enabled: erp.enabled !== false,
191
+ url,
192
+ key,
193
+ };
194
+ }
195
+
196
+ function uploadSnapshot(snapshot, options = {}) {
197
+ const cfg = options.erp || erpConfig(options);
198
+ if (!cfg.enabled) {
199
+ return Promise.reject(new Error("ERP disabled in Qualia config"));
200
+ }
201
+ if (!cfg.key) {
202
+ return Promise.reject(new Error("ERP API key missing in Qualia install"));
203
+ }
204
+
205
+ const endpoint = new URL("/api/v1/project-snapshots", cfg.url);
206
+ const payload = JSON.stringify(snapshot);
207
+ const transport = endpoint.protocol === "http:" ? http : https;
208
+
209
+ return new Promise((resolve, reject) => {
210
+ const req = transport.request(
211
+ endpoint,
212
+ {
213
+ method: "POST",
214
+ headers: {
215
+ Authorization: `Bearer ${cfg.key}`,
216
+ "Content-Type": "application/json",
217
+ "Content-Length": Buffer.byteLength(payload),
218
+ "User-Agent": "qualia-framework-project-snapshot",
219
+ },
220
+ timeout: options.timeout || 15000,
221
+ },
222
+ (res) => {
223
+ let body = "";
224
+ res.setEncoding("utf8");
225
+ res.on("data", (chunk) => {
226
+ body += chunk;
227
+ });
228
+ res.on("end", () => {
229
+ let parsed = null;
230
+ try {
231
+ parsed = body ? JSON.parse(body) : null;
232
+ } catch {
233
+ parsed = body;
234
+ }
235
+ if (res.statusCode >= 200 && res.statusCode < 300) {
236
+ resolve({ status: res.statusCode, body: parsed });
237
+ } else {
238
+ const message =
239
+ typeof parsed === "object" && parsed && parsed.message
240
+ ? parsed.message
241
+ : body || `HTTP ${res.statusCode}`;
242
+ reject(new Error(`ERP snapshot upload failed (${res.statusCode}): ${message}`));
243
+ }
244
+ });
245
+ }
246
+ );
247
+ req.on("timeout", () => {
248
+ req.destroy(new Error("ERP snapshot upload timed out"));
249
+ });
250
+ req.on("error", reject);
251
+ req.write(payload);
252
+ req.end();
253
+ });
254
+ }
255
+
256
+ function parseArgs(argv) {
257
+ return {
258
+ upload: argv.includes("--upload"),
259
+ write: argv.includes("--write"),
260
+ pretty: argv.includes("--pretty"),
261
+ };
262
+ }
263
+
264
+ if (require.main === module) {
265
+ (async () => {
266
+ const args = parseArgs(process.argv.slice(2));
267
+ const snapshot = buildSnapshot();
268
+ if (args.write) {
269
+ const file = writeSnapshot(snapshot);
270
+ process.stdout.write(`${file}\n`);
271
+ }
272
+ if (args.upload) {
273
+ const response = await uploadSnapshot(snapshot);
274
+ process.stdout.write(`${JSON.stringify(response.body || { ok: true })}\n`);
275
+ }
276
+ if (!args.write && !args.upload) {
277
+ process.stdout.write(`${JSON.stringify(snapshot, null, args.pretty ? 2 : 0)}\n`);
278
+ }
279
+ })().catch((error) => {
280
+ console.error(`project-snapshot failed: ${error.message}`);
281
+ process.exit(1);
282
+ });
283
+ }
284
+
285
+ module.exports = {
286
+ buildSnapshot,
287
+ erpConfig,
288
+ parseJourneyMilestones,
289
+ repoSlug,
290
+ uploadSnapshot,
291
+ uuid,
292
+ writeSnapshot,
293
+ };
package/bin/qualia-ui.js CHANGED
@@ -42,6 +42,17 @@ const BOLD = "\x1b[1m";
42
42
  const RULE = "━".repeat(42);
43
43
  const RULE_DIM = `${DIM2}${RULE}${RESET}`;
44
44
 
45
+ function qualiaHome() {
46
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
47
+ const parent = path.basename(path.dirname(__dirname));
48
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
49
+ const claude = path.join(os.homedir(), ".claude");
50
+ if (fs.existsSync(path.join(claude, ".qualia-config.json"))) return claude;
51
+ return claude;
52
+ }
53
+
54
+ const QUALIA_HOME = qualiaHome();
55
+
45
56
  // ─── Action Labels ───────────────────────────────────────
46
57
  const ACTIONS = {
47
58
  router: { label: "SMART ROUTER", glyph: "⬢" },
@@ -76,7 +87,7 @@ const ACTIONS = {
76
87
  // ─── State Reading ───────────────────────────────────────
77
88
  function readState() {
78
89
  try {
79
- const statePath = path.join(os.homedir(), ".claude", "bin", "state.js");
90
+ const statePath = path.join(QUALIA_HOME, "bin", "state.js");
80
91
  const r = spawnSync(process.execPath, [statePath, "check"], {
81
92
  encoding: "utf8",
82
93
  timeout: 3000,
@@ -91,7 +102,7 @@ function readState() {
91
102
 
92
103
  function readConfig() {
93
104
  try {
94
- const f = path.join(os.homedir(), ".claude", ".qualia-config.json");
105
+ const f = path.join(QUALIA_HOME, ".qualia-config.json");
95
106
  return JSON.parse(fs.readFileSync(f, "utf8"));
96
107
  } catch {
97
108
  return {};
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+ const fs = require("fs");
3
+ const os = require("os");
4
+ const path = require("path");
5
+ const { spawnSync } = require("child_process");
6
+
7
+ function readJson(file, fallback = {}) {
8
+ try {
9
+ return JSON.parse(fs.readFileSync(file, "utf8"));
10
+ } catch {
11
+ return fallback;
12
+ }
13
+ }
14
+
15
+ function readText(file, fallback = "") {
16
+ try {
17
+ return fs.readFileSync(file, "utf8");
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ }
22
+
23
+ function qualiaHome(home = os.homedir()) {
24
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
25
+ const parent = path.basename(path.dirname(__dirname));
26
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
27
+ const claude = path.join(home, ".claude");
28
+ if (fs.existsSync(path.join(claude, ".qualia-config.json"))) return claude;
29
+ return claude;
30
+ }
31
+
32
+ function git(args, cwd) {
33
+ const result = spawnSync("git", args, {
34
+ cwd,
35
+ encoding: "utf8",
36
+ timeout: 3000,
37
+ });
38
+ return result.status === 0 ? result.stdout.trim() : "";
39
+ }
40
+
41
+ function repoSlug(remote) {
42
+ return (remote || "")
43
+ .replace(/^git@github\.com:/, "github.com/")
44
+ .replace(/^https?:\/\//, "")
45
+ .replace(/\.git$/, "")
46
+ .split("/")
47
+ .filter(Boolean)
48
+ .pop();
49
+ }
50
+
51
+ function uuid(value) {
52
+ if (typeof value !== "string") return "";
53
+ const trimmed = value.trim();
54
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trimmed)
55
+ ? trimmed
56
+ : "";
57
+ }
58
+
59
+ function sessionDurationMinutes(startedAt, submittedAt) {
60
+ if (!startedAt) return 0;
61
+ const startMs = Date.parse(startedAt);
62
+ const endMs = Date.parse(submittedAt) || Date.now();
63
+ if (Number.isNaN(startMs) || endMs <= startMs) return 0;
64
+ return Math.round((endMs - startMs) / 60000);
65
+ }
66
+
67
+ function recentCommitHashes(cwd) {
68
+ const out = git(["log", "--oneline", "--since=8 hours ago", "--format=%h"], cwd);
69
+ return out ? out.split("\n").filter(Boolean) : [];
70
+ }
71
+
72
+ function buildPayload(options = {}) {
73
+ const cwd = options.cwd || process.cwd();
74
+ const home = options.home || os.homedir();
75
+ const env = options.env || process.env;
76
+ const trackingPath = options.trackingPath || path.join(cwd, ".planning", "tracking.json");
77
+ const configPath = options.configPath || path.join(qualiaHome(home), ".qualia-config.json");
78
+ const reportFile = options.reportFile || env.REPORT_FILE;
79
+ const submittedAt = env.SUBMITTED_AT || new Date().toISOString();
80
+
81
+ const tracking = readJson(trackingPath, {});
82
+ const config = readJson(configPath, {});
83
+ const notes = readText(reportFile, "").substring(0, 60000);
84
+ const gitRemote = tracking.git_remote || git(["config", "--get", "remote.origin.url"], cwd);
85
+ const projectKey = tracking.project_id || repoSlug(gitRemote) || path.basename(cwd);
86
+ const phase = tracking.phase;
87
+
88
+ return {
89
+ project: tracking.project || path.basename(cwd),
90
+ project_id: projectKey,
91
+ team_id: tracking.team_id || "qualia-solutions",
92
+ git_remote: gitRemote,
93
+ ...(uuid(tracking.erp_project_id) ? { erp_project_id: uuid(tracking.erp_project_id) } : {}),
94
+ ...(uuid(tracking.client_id) ? { client_id: uuid(tracking.client_id) } : {}),
95
+ ...(uuid(tracking.workspace_id) ? { workspace_id: uuid(tracking.workspace_id) } : {}),
96
+ client: tracking.client || "",
97
+ client_report_id: env.CLIENT_REPORT_ID || "",
98
+ framework_version: config.version || "",
99
+ milestone: tracking.milestone || 1,
100
+ milestone_name: tracking.milestone_name || "",
101
+ milestones: Array.isArray(tracking.milestones) ? tracking.milestones : [],
102
+ phase,
103
+ phase_name: tracking.phase_name,
104
+ total_phases: tracking.total_phases,
105
+ status: tracking.status,
106
+ tasks_done: tracking.tasks_done || 0,
107
+ tasks_total: tracking.tasks_total || 0,
108
+ verification: tracking.verification || "pending",
109
+ gap_cycles: (tracking.gap_cycles || {})[String(phase)] || 0,
110
+ build_count: tracking.build_count || 0,
111
+ deploy_count: tracking.deploy_count || 0,
112
+ deployed_url: tracking.deployed_url || "",
113
+ ...(tracking.session_started_at ? { session_started_at: tracking.session_started_at } : {}),
114
+ ...(tracking.last_pushed_at ? { last_pushed_at: tracking.last_pushed_at } : {}),
115
+ session_duration_minutes: sessionDurationMinutes(tracking.session_started_at, submittedAt),
116
+ lifetime: tracking.lifetime || {},
117
+ commits: recentCommitHashes(cwd),
118
+ notes,
119
+ submitted_by: env.SUBMITTED_BY || "unknown",
120
+ submitted_at: submittedAt,
121
+ };
122
+ }
123
+
124
+ if (require.main === module) {
125
+ try {
126
+ process.stdout.write(`${JSON.stringify(buildPayload())}\n`);
127
+ } catch (error) {
128
+ console.error(`report-payload failed: ${error.message}`);
129
+ process.exit(1);
130
+ }
131
+ }
132
+
133
+ module.exports = {
134
+ buildPayload,
135
+ repoSlug,
136
+ uuid,
137
+ };
@@ -17,7 +17,7 @@
17
17
  * Builder agents call this BEFORE commit. A non-zero exit blocks the commit.
18
18
  */
19
19
 
20
- import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
20
+ import { readFileSync, readdirSync, statSync, existsSync, watch as fsWatch } from "node:fs";
21
21
  import { join, extname, relative, resolve } from "node:path";
22
22
  import { argv, exit, cwd } from "node:process";
23
23
 
@@ -34,11 +34,11 @@ const RULES = [
34
34
  {
35
35
  id: "ABS-FONT",
36
36
  severity: CRITICAL,
37
- label: "Banned font (Inter/Roboto/Arial/system-ui/Space Grotesk)",
37
+ label: "Banned font (Inter/Roboto/Arial/Helvetica/system-ui/Space Grotesk/Montserrat/Poppins/Lato/Open Sans)",
38
38
  fileGlob: /\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/,
39
- pattern: /(font-family|fontFamily)[^;{,]*(['"`])(Inter|Roboto|Arial|Helvetica|system-ui|Space\s*Grotesk)\b/i,
39
+ pattern: /(font-family|fontFamily)[^;{,]*(['"`])(Inter|Roboto|Arial|Helvetica|system-ui|Space\s*Grotesk|Montserrat|Poppins|Lato|Open\s*Sans)\b/i,
40
40
  allow: /Inter\s*Display|Inter\s*Tight/,
41
- fix: "Replace with a distinctive font (Fraunces, Geist, Söhne, JetBrains Mono, etc). See DESIGN.md §3.",
41
+ fix: "Replace with a distinctive font (Fraunces, Geist, Söhne, JetBrains Mono, etc). See DESIGN.md §3 and qualia-design/design-brand.md.",
42
42
  },
43
43
  {
44
44
  id: "ABS-PURPLE-GRAD",
@@ -242,9 +242,10 @@ function scanFile(path) {
242
242
 
243
243
  // ── CLI ───────────────────────────────────────────────────────────────
244
244
  function parseArgs(argv) {
245
- const args = { paths: [], json: false, severity: null, help: false };
245
+ const args = { paths: [], json: false, severity: null, help: false, watch: false };
246
246
  for (const a of argv.slice(2)) {
247
247
  if (a === "--json") args.json = true;
248
+ else if (a === "--watch") args.watch = true;
248
249
  else if (a === "--help" || a === "-h") args.help = true;
249
250
  else if (a.startsWith("--severity=")) args.severity = a.split("=")[1];
250
251
  else if (a.startsWith("--")) {
@@ -259,7 +260,7 @@ function help() {
259
260
  console.log(`slop-detect — Qualia anti-pattern scanner
260
261
 
261
262
  Usage:
262
- slop-detect [path ...] [--json] [--severity=critical|high|medium|low]
263
+ slop-detect [path ...] [--json] [--severity=critical|high|medium|low] [--watch]
263
264
 
264
265
  Examples:
265
266
  slop-detect # scan whole repo
@@ -267,6 +268,7 @@ Examples:
267
268
  slop-detect app/ # scan a directory
268
269
  slop-detect --severity=critical # only critical findings
269
270
  slop-detect --json > slop.json # machine-readable
271
+ slop-detect src/ --watch # re-scan on file change (proactive)
270
272
 
271
273
  Exit codes:
272
274
  0 no critical findings
@@ -281,16 +283,86 @@ const RESET = "\x1b[0m";
281
283
  const DIM = "\x1b[2m";
282
284
  const BOLD = "\x1b[1m";
283
285
 
286
+ function scanAndReport(targets, args) {
287
+ const files = [];
288
+ for (const t of targets) {
289
+ let st;
290
+ try { st = statSync(t); } catch { continue; }
291
+ if (st.isFile()) files.push(t);
292
+ else for (const f of walk(t)) files.push(f);
293
+ }
294
+ const findings = [];
295
+ for (const f of files) findings.push(...scanFile(f));
296
+ const minSev = args.severity ? severityOrder(args.severity) : 1;
297
+ const filtered = findings.filter((f) => severityOrder(f.severity) >= minSev);
298
+ const stamp = new Date().toISOString().split("T")[1].split(".")[0];
299
+ if (filtered.length === 0) {
300
+ console.log(`${DIM}[${stamp}]${RESET} ${BOLD}\x1b[32m✓ no slop${RESET} (${files.length} files)`);
301
+ } else {
302
+ const crit = filtered.filter((f) => f.severity === CRITICAL).length;
303
+ console.log(`${DIM}[${stamp}]${RESET} ${severityColor("critical")}${BOLD}${filtered.length} findings, ${crit} critical${RESET} (${files.length} files)`);
304
+ for (const f of filtered.slice(0, 5)) {
305
+ const rel = relative(cwd(), f.file);
306
+ const color = severityColor(f.severity);
307
+ console.log(` ${color}●${RESET} ${rel}:${f.line} ${DIM}[${f.rule}]${RESET} ${f.label}`);
308
+ }
309
+ if (filtered.length > 5) console.log(` ${DIM}…and ${filtered.length - 5} more${RESET}`);
310
+ }
311
+ }
312
+
313
+ function runWatch(targets, args) {
314
+ console.log(`${BOLD}slop-detect --watch${RESET} ${DIM}(targets: ${targets.map((t) => relative(cwd(), t) || ".").join(", ")})${RESET}`);
315
+ console.log(`${DIM}Ctrl+C to stop.${RESET}\n`);
316
+ // Initial scan
317
+ scanAndReport(targets, args);
318
+ // Debounce so a flurry of writes coalesces into one rescan
319
+ let pending = null;
320
+ const trigger = () => {
321
+ if (pending) clearTimeout(pending);
322
+ pending = setTimeout(() => {
323
+ pending = null;
324
+ scanAndReport(targets, args);
325
+ }, 200);
326
+ };
327
+ const watchers = [];
328
+ for (const t of targets) {
329
+ try {
330
+ const isDir = statSync(t).isDirectory();
331
+ const w = fsWatch(t, isDir ? { recursive: true } : {}, (_evt, filename) => {
332
+ if (!filename) return trigger();
333
+ // Only act on tracked file extensions
334
+ if (!/\.(tsx|jsx|ts|js|css|scss|html|svelte|vue|astro)$/.test(filename)) return;
335
+ trigger();
336
+ });
337
+ watchers.push(w);
338
+ } catch (e) {
339
+ console.error(`watch: ${t} — ${e.message}`);
340
+ }
341
+ }
342
+ // Keep alive
343
+ process.on("SIGINT", () => {
344
+ for (const w of watchers) try { w.close(); } catch {}
345
+ process.exit(0);
346
+ });
347
+ }
348
+
284
349
  function main() {
285
350
  const args = parseArgs(argv);
286
351
  if (args.help) { help(); exit(0); }
287
352
 
288
- const targets = args.paths.length ? args.paths.map(p => resolve(p)) : ["app", "components", "src", "lib", "pages"]
289
- .map(d => resolve(cwd(), d))
290
- .filter(d => existsSync(d));
353
+ const targets = args.paths.length
354
+ ? args.paths.map((p) => resolve(p))
355
+ : [
356
+ "app", "components", "src", "lib", "pages",
357
+ "packages", "apps", // monorepo conventions (turbo, nx, pnpm workspaces)
358
+ ]
359
+ .map((d) => resolve(cwd(), d))
360
+ .filter((d) => existsSync(d));
291
361
 
292
362
  if (targets.length === 0) targets.push(resolve(cwd()));
293
363
 
364
+ if (args.watch) return runWatch(targets, args);
365
+
294
366
  // Collect files
295
367
  const files = [];
296
368
  for (const t of targets) {
package/bin/state.js CHANGED
@@ -145,7 +145,11 @@ function _pruneTraces(traceDir) {
145
145
 
146
146
  function _trace(event, result, data) {
147
147
  try {
148
- const traceDir = path.join(require("os").homedir(), ".claude", ".qualia-traces");
148
+ const os = require("os");
149
+ const parent = path.basename(path.dirname(__dirname));
150
+ const qualiaHome = process.env.QUALIA_HOME ||
151
+ (parent === ".codex" || parent === ".claude" ? path.dirname(__dirname) : path.join(os.homedir(), ".claude"));
152
+ const traceDir = path.join(qualiaHome, ".qualia-traces");
149
153
  if (!fs.existsSync(traceDir)) fs.mkdirSync(traceDir, { recursive: true });
150
154
  // Prune ~1% of the time (cheap on most invocations, bounded over time).
151
155
  if (Math.random() < 0.01) _pruneTraces(traceDir);
@@ -867,6 +871,9 @@ function cmdInit(opts) {
867
871
  assigned_to: opts.assigned_to || (prevLife ? prevLife.assigned_to : ""),
868
872
  team_id: opts.team_id || (prevLife ? prevLife.team_id || "" : ""),
869
873
  project_id: opts.project_id || (prevLife ? prevLife.project_id || "" : ""),
874
+ erp_project_id: opts.erp_project_id || (prevLife ? prevLife.erp_project_id || "" : ""),
875
+ client_id: opts.client_id || (prevLife ? prevLife.client_id || "" : ""),
876
+ workspace_id: opts.workspace_id || (prevLife ? prevLife.workspace_id || "" : ""),
870
877
  git_remote: opts.git_remote || (prevLife ? prevLife.git_remote || "" : ""),
871
878
  milestone: prevLife ? prevLife.milestone : 1,
872
879
  milestone_name: opts.milestone_name || (prevLife ? prevLife.milestone_name || "" : ""),
package/bin/statusline.js CHANGED
@@ -13,6 +13,18 @@ const path = require("path");
13
13
  const { spawnSync } = require("child_process");
14
14
  const HOME = os.homedir();
15
15
 
16
+ function qualiaHome() {
17
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
18
+ const normalized = __dirname.split(path.sep);
19
+ const parent = path.basename(path.dirname(__dirname));
20
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
21
+ const claude = path.join(HOME, ".claude");
22
+ if (fs.existsSync(path.join(claude, ".qualia-config.json"))) return claude;
23
+ return claude;
24
+ }
25
+
26
+ const QUALIA_HOME = qualiaHome();
27
+
16
28
  // ─── Colors (matches bin/qualia-ui.js palette) ───────────
17
29
  const TEAL = "\x1b[38;2;0;206;209m";
18
30
  const TEAL_GLOW = "\x1b[38;2;0;170;175m";
@@ -230,7 +242,7 @@ try {
230
242
  // BOTH forward and backward slashes so Windows installs (where DIR contains
231
243
  // `\`) get a correct key and the memory count renders.
232
244
  const dirKey = DIR.replace(/[\/\\]/g, "-");
233
- const memDir = path.join(HOME, ".claude", "projects", dirKey, "memory");
245
+ const memDir = path.join(QUALIA_HOME, "projects", dirKey, "memory");
234
246
  if (fs.existsSync(memDir)) {
235
247
  const files = fs.readdirSync(memDir).filter(f => f.endsWith(".md") && f !== "MEMORY.md");
236
248
  MEMORY_COUNT = files.length;
@@ -243,7 +255,7 @@ try {
243
255
  // missing (pre-install, broken install, or running outside a Qualia env).
244
256
  let QUALIA_FIRST_NAME = "";
245
257
  try {
246
- const configPath = path.join(HOME, ".claude", ".qualia-config.json");
258
+ const configPath = path.join(QUALIA_HOME, ".qualia-config.json");
247
259
  if (fs.existsSync(configPath)) {
248
260
  const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
249
261
  const fullName = String(cfg.installed_by || "").trim();