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.
- package/AGENTS.md +2 -1
- package/CLAUDE.md +2 -1
- package/README.md +45 -29
- package/agents/builder.md +1 -5
- package/agents/plan-checker.md +1 -1
- package/agents/planner.md +2 -6
- package/agents/qa-browser.md +3 -3
- package/agents/roadmapper.md +2 -2
- package/agents/verifier.md +7 -9
- package/agents/visual-evaluator.md +1 -3
- package/bin/cli.js +370 -205
- package/bin/erp-retry.js +11 -3
- package/bin/install.js +383 -55
- package/bin/knowledge-flush.js +25 -13
- package/bin/knowledge.js +11 -1
- package/bin/project-snapshot.js +293 -0
- package/bin/qualia-ui.js +13 -2
- package/bin/report-payload.js +137 -0
- package/bin/slop-detect.mjs +81 -9
- package/bin/state.js +8 -1
- package/bin/statusline.js +14 -2
- package/docs/archive/CHANGELOG-pre-v4.md +855 -0
- package/docs/changelog-v6.html +864 -0
- package/docs/ecosystem-operating-model.md +121 -0
- package/docs/erp-contract.md +74 -21
- package/docs/onboarding.html +2 -2
- package/docs/release.md +44 -0
- package/docs/reviews/v6.2.1-revival-audit.md +53 -0
- package/docs/reviews/v6.2.2-memory-erp-audit.md +41 -0
- package/docs/reviews/v6.2.3-erp-id-guard.md +15 -0
- package/guide.md +28 -3
- package/hooks/auto-update.js +20 -10
- package/hooks/branch-guard.js +10 -2
- package/hooks/env-empty-guard.js +15 -5
- package/hooks/git-guardrails.js +10 -1
- package/hooks/migration-guard.js +4 -1
- package/hooks/pre-deploy-gate.js +11 -1
- package/hooks/pre-push.js +43 -106
- package/hooks/session-start.js +22 -14
- package/hooks/stop-session-log.js +11 -3
- package/hooks/supabase-destructive-guard.js +11 -1
- package/hooks/vercel-account-guard.js +12 -3
- package/package.json +4 -3
- package/qualia-design/design-reference.md +2 -1
- package/qualia-design/frontend.md +4 -4
- package/rules/one-opinion.md +59 -0
- package/rules/trust-boundary.md +35 -0
- package/skills/qualia-feature/SKILL.md +5 -5
- package/skills/qualia-flush/SKILL.md +5 -7
- package/skills/qualia-hook-gen/SKILL.md +1 -1
- package/skills/qualia-learn/SKILL.md +1 -0
- package/skills/qualia-map/SKILL.md +2 -1
- package/skills/qualia-milestone/SKILL.md +2 -2
- package/skills/qualia-new/SKILL.md +6 -6
- package/skills/qualia-optimize/SKILL.md +1 -1
- package/skills/qualia-plan/SKILL.md +1 -1
- package/skills/qualia-polish/REFERENCE.md +8 -6
- package/skills/qualia-polish/SKILL.md +11 -9
- package/skills/qualia-polish/scripts/loop.mjs +18 -6
- package/skills/qualia-postmortem/SKILL.md +1 -1
- package/skills/qualia-report/SKILL.md +6 -42
- package/skills/qualia-road/SKILL.md +17 -5
- package/skills/qualia-verify/SKILL.md +3 -3
- package/skills/qualia-vibe/SKILL.md +226 -0
- package/skills/qualia-vibe/scripts/extract.mjs +141 -0
- package/skills/qualia-vibe/scripts/tokens.mjs +342 -0
- package/templates/help.html +10 -3
- package/templates/knowledge/agents.md +3 -3
- package/templates/knowledge/index.md +1 -1
- package/templates/tracking.json +3 -0
- package/templates/work-packet.md +46 -0
- package/tests/bin.test.sh +423 -25
- package/tests/hooks.test.sh +1 -8
- package/tests/install-smoke.test.sh +137 -0
- package/tests/published-install-smoke.test.sh +126 -0
- package/tests/refs.test.sh +43 -1
- package/tests/run-all.sh +49 -0
- package/tests/runner.js +19 -33
- package/tests/slop-detect.test.sh +11 -5
- package/tests/state.test.sh +4 -1
- 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
|
-
|
|
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(
|
|
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(
|
|
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
|
+
};
|
package/bin/slop-detect.mjs
CHANGED
|
@@ -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
|
|
289
|
-
.map(
|
|
290
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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();
|