qualia-framework 6.1.0 → 6.2.9

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 (59) hide show
  1. package/README.md +39 -26
  2. package/agents/roadmapper.md +1 -1
  3. package/bin/cli.js +339 -200
  4. package/bin/codex-goal.js +92 -0
  5. package/bin/erp-retry.js +11 -3
  6. package/bin/install.js +483 -55
  7. package/bin/knowledge-flush.js +25 -13
  8. package/bin/knowledge.js +11 -1
  9. package/bin/project-snapshot.js +293 -0
  10. package/bin/qualia-ui.js +13 -2
  11. package/bin/report-payload.js +137 -0
  12. package/bin/state.js +8 -1
  13. package/bin/statusline.js +14 -2
  14. package/docs/changelog-v6.html +864 -0
  15. package/docs/ecosystem-operating-model.md +121 -0
  16. package/docs/erp-contract.md +74 -21
  17. package/docs/onboarding.html +1 -1
  18. package/docs/release.md +44 -0
  19. package/docs/reviews/v6.2.1-revival-audit.md +53 -0
  20. package/docs/reviews/v6.2.2-memory-erp-audit.md +41 -0
  21. package/docs/reviews/v6.2.3-erp-id-guard.md +15 -0
  22. package/guide.md +16 -4
  23. package/hooks/auto-update.js +14 -7
  24. package/hooks/branch-guard.js +10 -2
  25. package/hooks/env-empty-guard.js +10 -1
  26. package/hooks/git-guardrails.js +10 -1
  27. package/hooks/migration-guard.js +4 -1
  28. package/hooks/pre-deploy-gate.js +38 -1
  29. package/hooks/pre-push.js +56 -157
  30. package/hooks/session-start.js +22 -14
  31. package/hooks/stop-session-log.js +11 -3
  32. package/hooks/supabase-destructive-guard.js +11 -1
  33. package/hooks/vercel-account-guard.js +12 -3
  34. package/package.json +3 -2
  35. package/rules/codex-goal.md +46 -0
  36. package/skills/qualia-build/SKILL.md +4 -0
  37. package/skills/qualia-feature/SKILL.md +4 -0
  38. package/skills/qualia-map/SKILL.md +1 -1
  39. package/skills/qualia-milestone/SKILL.md +1 -1
  40. package/skills/qualia-optimize/SKILL.md +1 -1
  41. package/skills/qualia-plan/SKILL.md +4 -0
  42. package/skills/qualia-polish/SKILL.md +2 -2
  43. package/skills/qualia-report/SKILL.md +6 -43
  44. package/skills/qualia-road/SKILL.md +1 -1
  45. package/skills/qualia-verify/SKILL.md +1 -1
  46. package/templates/help.html +1 -1
  47. package/templates/knowledge/agents.md +3 -3
  48. package/templates/knowledge/index.md +1 -1
  49. package/templates/tracking.json +3 -0
  50. package/templates/work-packet.md +46 -0
  51. package/tests/bin.test.sh +411 -13
  52. package/tests/hooks.test.sh +1 -8
  53. package/tests/install-smoke.test.sh +137 -0
  54. package/tests/published-install-smoke.test.sh +126 -0
  55. package/tests/refs.test.sh +42 -0
  56. package/tests/run-all.sh +1 -0
  57. package/tests/runner.js +19 -33
  58. package/tests/state.test.sh +4 -1
  59. package/hooks/pre-compact.js +0 -127
@@ -5,8 +5,8 @@
5
5
  // CI/scheduled job) without an interactive Claude Code session. Closes the
6
6
  // memory loop end-to-end:
7
7
  //
8
- // Stop hook (auto, every turn) → ~/.claude/knowledge/daily-log/{date}.md
9
- // THIS SCRIPT (weekly cron) → spawns `claude -p "/qualia-flush --days 7"`
8
+ // Stop hook (auto, every turn) → <install-home>/knowledge/daily-log/{date}.md
9
+ // THIS SCRIPT (weekly cron) → spawns the installed agent CLI
10
10
  // /qualia-flush → promotes raw → curated tier
11
11
  // bin/knowledge.js (every spawn) → reads index.md → reaches the right file
12
12
  //
@@ -20,11 +20,11 @@
20
20
  // 0 3 * * 0 node ~/.claude/bin/knowledge-flush.js >> ~/.claude/.qualia-flush.log 2>&1
21
21
  //
22
22
  // Behavior:
23
- // • If `claude` CLI isn't on PATH, exits 0 with a logged warning. Cron
23
+ // • If the required agent CLI isn't on PATH, exits 0 with a logged warning. Cron
24
24
  // spam is worse than a missed flush — a real failure surfaces in the
25
25
  // log file the user is presumably watching.
26
26
  // • If the daily-log dir is empty (nothing to flush), exits 0 silently.
27
- // • If `claude -p` returns non-zero, exits 1 with the error captured in
27
+ // • If the agent CLI returns non-zero, exits 1 with the error captured in
28
28
  // the log so cron can be configured to alert on it.
29
29
  // • Writes one structured JSONL line per run to ~/.claude/.qualia-flush.log
30
30
  // so the user can audit "when did the last 5 flushes run, what did they
@@ -38,9 +38,17 @@ const os = require("os");
38
38
  const { spawnSync } = require("child_process");
39
39
 
40
40
  const HOME = os.homedir();
41
- const KNOWLEDGE_DIR = path.join(HOME, ".claude", "knowledge");
41
+ function qualiaHome() {
42
+ if (process.env.QUALIA_HOME) return process.env.QUALIA_HOME;
43
+ const parent = path.basename(path.dirname(__dirname));
44
+ if (parent === ".codex" || parent === ".claude") return path.dirname(__dirname);
45
+ return path.join(HOME, ".claude");
46
+ }
47
+
48
+ const QUALIA_HOME = qualiaHome();
49
+ const KNOWLEDGE_DIR = path.join(QUALIA_HOME, "knowledge");
42
50
  const DAILY_DIR = path.join(KNOWLEDGE_DIR, "daily-log");
43
- const LOG_FILE = path.join(HOME, ".claude", ".qualia-flush.log");
51
+ const LOG_FILE = path.join(QUALIA_HOME, ".qualia-flush.log");
44
52
 
45
53
  const _start = Date.now();
46
54
 
@@ -103,9 +111,11 @@ function dailyLogHasRecentEntries(windowDays) {
103
111
  }
104
112
 
105
113
  // ── Preflight ────────────────────────────────────────────
106
- const claudeBin = which("claude");
107
- if (!claudeBin) {
108
- logEvent({ event: "skipped", reason: "claude-cli-not-on-path" });
114
+ const IS_CODEX_INSTALL = path.basename(QUALIA_HOME) === ".codex";
115
+ const agentCli = IS_CODEX_INSTALL ? "codex" : "claude";
116
+ const agentBin = which(agentCli);
117
+ if (!agentBin) {
118
+ logEvent({ event: "skipped", reason: `${agentCli}-cli-not-on-path` });
109
119
  // Exit 0 — a missing CLI on the host running cron is a config issue, not
110
120
  // a flush failure. Don't spam alerts.
111
121
  process.exit(0);
@@ -117,11 +127,13 @@ if (!dailyLogHasRecentEntries(days)) {
117
127
  }
118
128
 
119
129
  // ── Run ──────────────────────────────────────────────────
120
- // `claude -p "<prompt>"` runs a single non-interactive turn. The skill body
121
- // invocation matches what the user would type at the prompt.
130
+ // `claude -p "<prompt>"` and `codex exec "<prompt>"` run a single
131
+ // non-interactive turn. The skill body invocation matches what the user would
132
+ // type at the prompt.
122
133
  const prompt = `/qualia-flush ${argv.join(" ")}`.trim();
123
134
 
124
- const result = spawnSync(claudeBin, ["-p", prompt], {
135
+ const cliArgs = IS_CODEX_INSTALL ? ["exec", prompt] : ["-p", prompt];
136
+ const result = spawnSync(agentBin, cliArgs, {
125
137
  encoding: "utf8",
126
138
  timeout: 5 * 60 * 1000, // 5 min hard cap — flush should never take this long
127
139
  shell: process.platform === "win32",
@@ -140,7 +152,7 @@ if (status !== 0) {
140
152
  stderr_tail: stderr.slice(-1000),
141
153
  });
142
154
  // Surface to stderr so cron's MAILTO sends an alert.
143
- console.error(`knowledge-flush: claude -p exited ${status}`);
155
+ console.error(`knowledge-flush: ${agentCli} exited ${status}`);
144
156
  if (stderr) console.error(stderr.slice(-2000));
145
157
  process.exit(1);
146
158
  }
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
+ };
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();