qualia-framework 4.0.0 → 4.0.5

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 (47) hide show
  1. package/CLAUDE.md +23 -11
  2. package/agents/plan-checker.md +1 -1
  3. package/agents/roadmapper.md +10 -5
  4. package/bin/cli.js +139 -17
  5. package/bin/install.js +47 -47
  6. package/bin/qualia-ui.js +2 -2
  7. package/bin/state.js +126 -9
  8. package/bin/statusline.js +63 -38
  9. package/docs/erp-contract.md +49 -2
  10. package/guide.md +1 -1
  11. package/hooks/migration-guard.js +23 -9
  12. package/hooks/pre-compact.js +39 -11
  13. package/hooks/pre-deploy-gate.js +3 -4
  14. package/hooks/pre-push.js +6 -3
  15. package/hooks/session-start.js +8 -8
  16. package/package.json +1 -1
  17. package/rules/frontend.md +5 -13
  18. package/skills/qualia/SKILL.md +5 -0
  19. package/skills/qualia-build/SKILL.md +10 -0
  20. package/skills/qualia-debug/SKILL.md +6 -0
  21. package/skills/qualia-design/SKILL.md +9 -1
  22. package/skills/qualia-discuss/SKILL.md +6 -0
  23. package/skills/qualia-handoff/SKILL.md +5 -0
  24. package/skills/qualia-help/SKILL.md +18 -4
  25. package/skills/qualia-idk/SKILL.md +6 -0
  26. package/skills/qualia-learn/SKILL.md +6 -0
  27. package/skills/qualia-map/SKILL.md +7 -0
  28. package/skills/qualia-milestone/SKILL.md +6 -0
  29. package/skills/qualia-new/SKILL.md +31 -4
  30. package/skills/qualia-optimize/SKILL.md +8 -0
  31. package/skills/qualia-pause/SKILL.md +5 -0
  32. package/skills/qualia-plan/SKILL.md +11 -1
  33. package/skills/qualia-polish/SKILL.md +8 -0
  34. package/skills/qualia-quick/SKILL.md +7 -0
  35. package/skills/qualia-report/SKILL.md +146 -60
  36. package/skills/qualia-research/SKILL.md +7 -0
  37. package/skills/qualia-resume/SKILL.md +3 -0
  38. package/skills/qualia-review/SKILL.md +7 -0
  39. package/skills/qualia-ship/SKILL.md +5 -0
  40. package/skills/qualia-skill-new/SKILL.md +6 -0
  41. package/skills/qualia-task/SKILL.md +8 -1
  42. package/skills/qualia-test/SKILL.md +7 -0
  43. package/skills/qualia-verify/SKILL.md +8 -0
  44. package/templates/help.html +4 -4
  45. package/templates/tracking.json +1 -0
  46. package/tests/hooks.test.sh +5 -5
  47. package/tests/runner.js +310 -3
package/bin/state.js CHANGED
@@ -9,6 +9,7 @@ const PLANNING = ".planning";
9
9
  const STATE_FILE = path.join(PLANNING, "STATE.md");
10
10
  const TRACKING_FILE = path.join(PLANNING, "tracking.json");
11
11
  const LOCK_FILE = path.join(PLANNING, ".state.lock");
12
+ const JOURNAL_FILE = path.join(PLANNING, ".state.journal");
12
13
 
13
14
  // ─── Atomic write (tmp + rename) ─────────────────────────
14
15
  // Prevents half-written files when SIGINT, OOM, or AV scanners
@@ -26,10 +27,60 @@ function atomicWrite(file, content) {
26
27
  }
27
28
  }
28
29
 
30
+ // ─── Write-ahead journal (two-file crash recovery) ──────
31
+ // STATE.md + tracking.json are written back-to-back. A SIGKILL or power
32
+ // loss between the two renames can leave the pair inconsistent. The
33
+ // journal captures the pre-write snapshot; if a journal is still present
34
+ // on next startup, we know the previous mutator crashed mid-write and
35
+ // restore both files to the pre-transaction state.
36
+ function writeJournal(preState, preTracking) {
37
+ if (!fs.existsSync(PLANNING)) return;
38
+ const payload = JSON.stringify({
39
+ ts: new Date().toISOString(),
40
+ pid: process.pid,
41
+ state: preState != null ? preState : null,
42
+ tracking: preTracking != null ? preTracking : null,
43
+ });
44
+ atomicWrite(JOURNAL_FILE, payload);
45
+ }
46
+ function clearJournal() {
47
+ try { fs.unlinkSync(JOURNAL_FILE); } catch {}
48
+ }
49
+ function recoverFromJournal() {
50
+ if (!fs.existsSync(JOURNAL_FILE)) return false;
51
+ try {
52
+ const raw = fs.readFileSync(JOURNAL_FILE, "utf8");
53
+ const j = JSON.parse(raw);
54
+ if (j.state != null) atomicWrite(STATE_FILE, j.state);
55
+ if (j.tracking != null) atomicWrite(TRACKING_FILE, j.tracking);
56
+ clearJournal();
57
+ try { _trace("state-journal", "recover", { from_pid: j.pid, journal_ts: j.ts }); } catch {}
58
+ return true;
59
+ } catch {
60
+ // Corrupt journal — best effort: remove so we don't loop on recovery.
61
+ clearJournal();
62
+ return false;
63
+ }
64
+ }
65
+
29
66
  // ─── Exclusive lock ──────────────────────────────────────
30
67
  // Prevents two concurrent state.js mutations from racing on the dual
31
68
  // STATE.md + tracking.json write. Read commands (check, validate-plan)
32
69
  // don't take the lock — only mutators do.
70
+
71
+ // Synchronous sleep without CPU spin. Atomics.wait on a zero-initialized
72
+ // SharedArrayBuffer blocks until the timeout elapses and yields the CPU.
73
+ function sleepSync(ms) {
74
+ try {
75
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
76
+ } catch {
77
+ // SharedArrayBuffer unavailable (extremely old runtimes) — last-resort
78
+ // tight loop, bounded to the requested duration.
79
+ const t = Date.now() + ms;
80
+ while (Date.now() < t) {}
81
+ }
82
+ }
83
+
33
84
  function acquireLock(timeoutMs = 5000) {
34
85
  if (!fs.existsSync(PLANNING)) return null; // nothing to lock yet
35
86
  const start = Date.now();
@@ -50,12 +101,13 @@ function acquireLock(timeoutMs = 5000) {
50
101
  continue;
51
102
  }
52
103
  } catch {}
53
- // Spin-wait briefly. State ops are fast; conflicts rare.
54
- const t = Date.now() + 50;
55
- while (Date.now() < t) {}
104
+ sleepSync(50);
56
105
  }
57
106
  }
58
- // Couldn't acquire — proceed unlocked rather than block the user.
107
+ // Couldn't acquire inside the budget — proceed unlocked rather than
108
+ // hard-block the user. Surface this in analytics so repeated contention
109
+ // is visible instead of silent.
110
+ try { _trace("state-lock", "fallthrough", { waited_ms: Date.now() - start }); } catch {}
59
111
  return null;
60
112
  }
61
113
 
@@ -142,6 +194,7 @@ function ensureLifetime(t) {
142
194
  if (typeof t.milestone !== "number") t.milestone = 1;
143
195
  if (typeof t.milestone_name !== "string") t.milestone_name = "";
144
196
  if (!Array.isArray(t.milestones)) t.milestones = [];
197
+ if (typeof t.report_seq !== "number") t.report_seq = 0;
145
198
  if (!t.lifetime || typeof t.lifetime !== "object") {
146
199
  t.lifetime = {
147
200
  tasks_completed: 0,
@@ -153,6 +206,26 @@ function ensureLifetime(t) {
153
206
  return t;
154
207
  }
155
208
 
209
+ // Parse JOURNEY.md and extract the human name of the Nth milestone.
210
+ // Matches headers like:
211
+ // ## Milestone 2 · Core Features
212
+ // ## Milestone 2 · Core Features [CURRENT]
213
+ // ## Milestone 5 · Handoff [FINAL]
214
+ // Returns "" if JOURNEY.md is absent or the milestone isn't in it.
215
+ function readNextMilestoneNameFromJourney(milestoneNum) {
216
+ try {
217
+ const journeyPath = path.join(PLANNING, "JOURNEY.md");
218
+ if (!fs.existsSync(journeyPath)) return "";
219
+ const content = fs.readFileSync(journeyPath, "utf8");
220
+ const re = new RegExp(`^##\\s+Milestone\\s+${milestoneNum}\\s*[·•-]\\s*([^\\n\\[]+)`, "m");
221
+ const m = content.match(re);
222
+ if (m && m[1]) return m[1].trim();
223
+ return "";
224
+ } catch {
225
+ return "";
226
+ }
227
+ }
228
+
156
229
  function readState() {
157
230
  try {
158
231
  return fs.readFileSync(STATE_FILE, "utf8");
@@ -613,14 +686,25 @@ function cmdTransition(opts) {
613
686
  t.deploy_count = (parseInt(t.deploy_count) || 0) + 1;
614
687
  }
615
688
 
616
- // Write both files
689
+ // Write both files. We write a journal snapshot of the pre-transition
690
+ // STATE.md + tracking.json first; if the process dies between the two
691
+ // real writes, the next invocation will see the journal and restore both
692
+ // files to the pre-transition state. Each individual write is torn-write
693
+ // safe (tmp + rename); the journal closes the gap between the two.
617
694
  const backupState = readState();
695
+ const backupTracking = (() => {
696
+ try { return fs.readFileSync(TRACKING_FILE, "utf8"); } catch { return null; }
697
+ })();
618
698
  try {
699
+ writeJournal(backupState, backupTracking);
619
700
  writeStateMd(s);
620
701
  writeTracking(t);
702
+ clearJournal();
621
703
  } catch (e) {
622
- // Revert STATE.md on failure (atomic so the revert itself is safe)
623
- if (backupState) atomicWrite(STATE_FILE, backupState);
704
+ // Revert whichever file is out of sync with pre-transition state.
705
+ try { if (backupState) atomicWrite(STATE_FILE, backupState); } catch {}
706
+ try { if (backupTracking) atomicWrite(TRACKING_FILE, backupTracking); } catch {}
707
+ clearJournal();
624
708
  return output(fail("WRITE_ERROR", e.message));
625
709
  }
626
710
 
@@ -1078,7 +1162,12 @@ function cmdCloseMilestone(opts) {
1078
1162
  t.lifetime.total_phases += (parseInt(t.total_phases) || 0);
1079
1163
  t.lifetime.last_closed_milestone = closedMilestone;
1080
1164
  t.milestone = closedMilestone + 1;
1081
- t.milestone_name = ""; // cleared; /qualia-milestone reads next one from JOURNEY.md
1165
+ // Try to pre-populate next milestone's name from JOURNEY.md so the ERP
1166
+ // tree view doesn't show a blank between close-milestone and the next
1167
+ // state.js init --force (which happens in /qualia-milestone step 7).
1168
+ // If JOURNEY.md is missing or unparseable, fall through with blank —
1169
+ // /qualia-milestone will still set it via init --force.
1170
+ t.milestone_name = readNextMilestoneNameFromJourney(t.milestone);
1082
1171
  t.last_updated = new Date().toISOString();
1083
1172
 
1084
1173
  writeTracking(t);
@@ -1175,6 +1264,27 @@ function cmdBackfillLifetime(opts) {
1175
1264
  });
1176
1265
  }
1177
1266
 
1267
+ // ─── Next Report ID ──────────────────────────────────────
1268
+ // Increments report_seq and returns the next QS-REPORT-NN id. Per-project
1269
+ // counter (lives in tracking.json). /qualia-report calls this to tag each
1270
+ // session report with a stable, human-readable client ID before POSTing
1271
+ // to the ERP. If --peek is passed, the next id is returned WITHOUT
1272
+ // incrementing — useful for --dry-run previews.
1273
+ function cmdNextReportId(opts) {
1274
+ const t = readTracking();
1275
+ if (!t) return output(fail("NO_PROJECT", "No .planning/ found."));
1276
+ ensureLifetime(t);
1277
+ const peek = !!opts.peek;
1278
+ const next = (parseInt(t.report_seq) || 0) + 1;
1279
+ const id = `QS-REPORT-${String(next).padStart(2, "0")}`;
1280
+ if (!peek) {
1281
+ t.report_seq = next;
1282
+ t.last_updated = new Date().toISOString();
1283
+ writeTracking(t);
1284
+ }
1285
+ output({ ok: true, action: "next-report-id", report_id: id, report_seq: next, peeked: peek });
1286
+ }
1287
+
1178
1288
  // ─── Output ──────────────────────────────────────────────
1179
1289
  function output(obj) {
1180
1290
  console.log(JSON.stringify(obj, null, 2));
@@ -1193,6 +1303,10 @@ const opts = parseArgs(rest);
1193
1303
  const READ_ONLY = new Set(["check", "validate-plan"]);
1194
1304
  let __lock = null;
1195
1305
  if (!READ_ONLY.has(cmd)) {
1306
+ // Before acquiring the lock, recover from any journal left by a crashed
1307
+ // previous mutator. Runs for mutators only; read commands should still
1308
+ // return the actual on-disk state even if it's mid-recovery.
1309
+ try { recoverFromJournal(); } catch {}
1196
1310
  __lock = acquireLock();
1197
1311
  process.on("exit", () => releaseLock(__lock));
1198
1312
  process.on("SIGINT", () => { releaseLock(__lock); process.exit(130); });
@@ -1222,11 +1336,14 @@ try {
1222
1336
  case "backfill-lifetime":
1223
1337
  cmdBackfillLifetime(opts);
1224
1338
  break;
1339
+ case "next-report-id":
1340
+ cmdNextReportId(opts);
1341
+ break;
1225
1342
  default:
1226
1343
  output(
1227
1344
  fail(
1228
1345
  "UNKNOWN_COMMAND",
1229
- `Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime> [--options]`
1346
+ `Usage: state.js <check|transition|init|fix|validate-plan|close-milestone|backfill-lifetime|next-report-id> [--options]`
1230
1347
  )
1231
1348
  );
1232
1349
  }
package/bin/statusline.js CHANGED
@@ -154,6 +154,8 @@ try {
154
154
  } catch {}
155
155
 
156
156
  // ─── Phase info from .planning/tracking.json ─────────────
157
+ // Shows: [M{n}·{milestoneName}] P{phase}/{total} T{done}/{total} {status} [!{blockers}]
158
+ // Every segment is optional — missing data is skipped, never rendered as a placeholder.
157
159
  let PHASE_INFO = "";
158
160
  try {
159
161
  const trackingPath = path.join(DIR, ".planning", "tracking.json");
@@ -162,12 +164,46 @@ try {
162
164
  const phase = Number(tracking.phase || 0) || 0;
163
165
  const total = Number(tracking.total_phases || 0) || 0;
164
166
  const status = String(tracking.status || "");
167
+ const milestone = Number(tracking.milestone || 0) || 0;
168
+ const milestoneName = String(tracking.milestone_name || "");
169
+ const tasksDone = Number(tracking.tasks_done || 0) || 0;
170
+ const tasksTotal = Number(tracking.tasks_total || 0) || 0;
171
+ const blockers = Array.isArray(tracking.blockers) ? tracking.blockers.length : 0;
172
+
173
+ const parts = [];
174
+
175
+ // Milestone: M{n}·{shortName} (short name trimmed to 14 chars)
176
+ if (milestone > 0) {
177
+ let mStr = `M${milestone}`;
178
+ if (milestoneName) {
179
+ const shortName = milestoneName.length > 14 ? milestoneName.slice(0, 13) + "…" : milestoneName;
180
+ mStr += `${DIM}·${RESET}${TEAL_GLOW}${shortName}`;
181
+ }
182
+ parts.push(`${TEAL}${mStr}${RESET}`);
183
+ }
184
+
185
+ // Phase: P{phase}/{total}
165
186
  if (total > 0) {
166
- const pdone = Math.floor((phase * 100) / total);
167
- const pfill = Math.max(0, Math.min(4, Math.floor(pdone / 25)));
168
- const pempt = 4 - pfill;
169
- const pbar = "●".repeat(pfill) + "○".repeat(pempt);
170
- PHASE_INFO = `${TEAL}${pbar}${RESET} ${WHITE}P${phase}/${total}${RESET} ${TEAL_GLOW}${status}${RESET}`;
187
+ parts.push(`${WHITE}P${phase}/${total}${RESET}`);
188
+ }
189
+
190
+ // Tasks within phase: T{done}/{total}
191
+ if (tasksTotal > 0) {
192
+ parts.push(`${DIM}T${RESET}${WHITE}${tasksDone}/${tasksTotal}${RESET}`);
193
+ }
194
+
195
+ // Status
196
+ if (status) {
197
+ parts.push(`${TEAL_GLOW}${status}${RESET}`);
198
+ }
199
+
200
+ // Blockers — red badge, only when > 0
201
+ if (blockers > 0) {
202
+ parts.push(`${RED}!${blockers}${RESET}`);
203
+ }
204
+
205
+ if (parts.length > 0) {
206
+ PHASE_INFO = parts.join(` ${DIM}·${RESET} `);
171
207
  }
172
208
  }
173
209
  } catch {}
@@ -175,7 +211,10 @@ try {
175
211
  // ─── Memory count ────────────────────────────────────────
176
212
  let MEMORY_COUNT = 0;
177
213
  try {
178
- const dirKey = DIR.replace(/\//g, "-");
214
+ // Claude Code uses a hyphenated encoding of the project directory. Replace
215
+ // BOTH forward and backward slashes so Windows installs (where DIR contains
216
+ // `\`) get a correct key and the memory count renders.
217
+ const dirKey = DIR.replace(/[\/\\]/g, "-");
179
218
  const memDir = path.join(HOME, ".claude", "projects", dirKey, "memory");
180
219
  if (fs.existsSync(memDir)) {
181
220
  const files = fs.readdirSync(memDir).filter(f => f.endsWith(".md") && f !== "MEMORY.md");
@@ -183,36 +222,22 @@ try {
183
222
  }
184
223
  } catch {}
185
224
 
186
- // ─── Hooks count ─────────────────────────────────────────
187
- let HOOKS_COUNT = 0;
225
+ // ─── Qualia identity: first name of the installed employee ─────────
226
+ // Read from ~/.claude/.qualia-config.json. Used as the "signature" at the
227
+ // end of line 2. Gracefully degrades to empty string if the config is
228
+ // missing (pre-install, broken install, or running outside a Qualia env).
229
+ let QUALIA_FIRST_NAME = "";
188
230
  try {
189
- const settingsPath = path.join(HOME, ".claude", "settings.json");
190
- if (fs.existsSync(settingsPath)) {
191
- const settings = JSON.parse(fs.readFileSync(settingsPath, "utf8"));
192
- if (settings.hooks) {
193
- for (const event of Object.values(settings.hooks)) {
194
- if (Array.isArray(event)) {
195
- for (const matcher of event) {
196
- if (matcher.hooks && Array.isArray(matcher.hooks)) {
197
- HOOKS_COUNT += matcher.hooks.length;
198
- }
199
- }
200
- }
201
- }
231
+ const configPath = path.join(HOME, ".claude", ".qualia-config.json");
232
+ if (fs.existsSync(configPath)) {
233
+ const cfg = JSON.parse(fs.readFileSync(configPath, "utf8"));
234
+ const fullName = String(cfg.installed_by || "").trim();
235
+ if (fullName) {
236
+ QUALIA_FIRST_NAME = fullName.split(/\s+/)[0] || "";
202
237
  }
203
238
  }
204
239
  } catch {}
205
240
 
206
- // ─── Skills count ────────────────────────────────────────
207
- let SKILLS_COUNT = 0;
208
- try {
209
- const skillsDir = path.join(HOME, ".claude", "skills");
210
- if (fs.existsSync(skillsDir)) {
211
- const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
212
- SKILLS_COUNT = entries.filter(e => e.isDirectory() || e.name.endsWith(".md")).length;
213
- }
214
- } catch {}
215
-
216
241
  // ─── Duration ────────────────────────────────────────────
217
242
  let DUR = "0s";
218
243
  try {
@@ -244,13 +269,13 @@ try {
244
269
  if (AGENT) LINE1 += ` ${DIM}│${RESET} ${TEAL}⚡${AGENT}${RESET}`;
245
270
  if (WORKTREE) LINE1 += ` ${DIM}│${RESET} ${TEAL_DIM}⎇ ${WORKTREE}${RESET}`;
246
271
  if (PHASE_INFO) LINE1 += ` ${DIM}│${RESET} ${PHASE_INFO}`;
247
- // Memory, hooks, skills context indicators with labels
248
- const contextParts = [];
249
- if (MEMORY_COUNT > 0) contextParts.push(`${DIM}mem${RESET} ${TEAL}${MEMORY_COUNT}${RESET}`);
250
- if (HOOKS_COUNT > 0) contextParts.push(`${DIM}hooks${RESET} ${TEAL_GLOW}${HOOKS_COUNT}${RESET}`);
251
- if (SKILLS_COUNT > 0) contextParts.push(`${DIM}skills${RESET} ${TEAL_DIM}${SKILLS_COUNT}${RESET}`);
252
- if (contextParts.length > 0) {
253
- LINE1 += ` ${DIM}│${RESET} ${contextParts.join(` ${DIM}·${RESET} `)}`;
272
+ // Memory the one context indicator that's actually project-specific
273
+ if (MEMORY_COUNT > 0) {
274
+ LINE1 += ` ${DIM}│${RESET} ${DIM}mem${RESET} ${TEAL}${MEMORY_COUNT}${RESET}`;
275
+ }
276
+ // Qualia member signature end of line 1 so it sits above line 2's model info
277
+ if (QUALIA_FIRST_NAME) {
278
+ LINE1 += ` ${DIM}│${RESET} ${TEAL}⬢${RESET} ${TEAL_GLOW}Qualia member${RESET}${DIM}:${RESET} ${WHITE}${QUALIA_FIRST_NAME}${RESET}`;
254
279
  }
255
280
  } catch {
256
281
  LINE1 = `${TEAL}⬢${RESET} ${WHITE}qualia${RESET}`;
@@ -28,8 +28,16 @@ Upload a session report.
28
28
  ```
29
29
  Authorization: Bearer <api-key>
30
30
  Content-Type: application/json
31
+ Idempotency-Key: <uuid> # optional; 24h replay window — see below
31
32
  ```
32
33
 
34
+ **Idempotency-Key behavior (v3.6+):**
35
+ When present, must be a valid UUID. Replays of the same key within 24h return
36
+ the original `report_id` with `Idempotent-Replay: true` response header and
37
+ 200 status — no new row is created. Invalid UUID format returns 400.
38
+ Independent of `client_report_id` UPSERT (both can be used together; see
39
+ below).
40
+
33
41
  **Request Body:**
34
42
  ```json
35
43
  {
@@ -38,7 +46,18 @@ Content-Type: application/json
38
46
  "team_id": "qualia-solutions",
39
47
  "git_remote": "github.com/QualiasolutionsCY/acme-portal",
40
48
  "client": "Client Name",
49
+ "client_report_id": "QS-REPORT-03",
41
50
  "milestone": 2,
51
+ "milestone_name": "Core Product",
52
+ "milestones": [
53
+ {
54
+ "num": 1,
55
+ "name": "Foundation",
56
+ "closed_at": "2026-04-10T18:00:00Z",
57
+ "phases_completed": 3,
58
+ "tasks_completed": 12
59
+ }
60
+ ],
42
61
  "phase": 2,
43
62
  "phase_name": "Authentication & Dashboard",
44
63
  "total_phases": 4,
@@ -77,11 +96,27 @@ accept both shapes: if object, use `gap_cycles[String(phase)] || 0`.
77
96
  ```json
78
97
  {
79
98
  "ok": true,
80
- "report_id": "rpt_abc123def456",
99
+ "report_id": "QS-REPORT-03",
81
100
  "message": "Report received"
82
101
  }
83
102
  ```
84
103
 
104
+ `report_id` semantics:
105
+ - **v4.0.4+ payloads** (`client_report_id` present): ERP echoes the
106
+ `client_report_id` string back as `report_id` for display consistency.
107
+ Example: request sends `client_report_id: "QS-REPORT-03"` → response
108
+ returns `report_id: "QS-REPORT-03"`.
109
+ - **Legacy payloads** (no `client_report_id`): ERP returns its internal UUID
110
+ (e.g. `"a5304d8b-a5ac-4e22-b0c0-fed5f50299bb"`) as `report_id`.
111
+
112
+ **Idempotent UPSERT on retry (v4.0.4+):**
113
+ When BOTH `project_id` and `client_report_id` are present, the ERP treats
114
+ `(project_id, client_report_id)` as a unique key and UPSERTs. Retries after
115
+ a transient failure produce the same row and return the same `report_id`
116
+ — no duplicate. This is stronger than the 24h Idempotency-Key window (which
117
+ is exact-replay only) because `client_report_id` uniqueness is enforced
118
+ permanently.
119
+
85
120
  **Response (401 Unauthorized):**
86
121
  ```json
87
122
  {
@@ -161,7 +196,15 @@ Authorization: Bearer <api-key>
161
196
  - When the API key file is missing or empty, the upload is skipped with a warning.
162
197
  - Network failures are non-blocking — the report is saved locally regardless.
163
198
  - The ERP reads `tracking.json` directly from git for real-time status (no API call needed for passive monitoring).
164
- - Reports are append-only — no update or delete endpoints exist.
199
+ - Reports are append-only — no PUT/PATCH/DELETE endpoints exist for
200
+ external callers. Internal idempotent UPSERT on `(project_id,
201
+ client_report_id)` retries is the one exception (see "Idempotent UPSERT
202
+ on retry" above).
203
+ - **`dry_run` retention (v4.0.4+):** The ERP deletes rows where
204
+ `dry_run = true AND submitted_at < now() - 7 days` via a daily cron at
205
+ 03:00 UTC. Production report views (list, project tree, email digests)
206
+ exclude `dry_run = true` rows at read time by default. Admins can opt in
207
+ via `includeDryRun: true` on the server-action readers for diagnostics.
165
208
  - `tracking.json` includes `milestone` and `lifetime` fields (added in v3.4). These survive across milestone resets and `state.js init` calls. For aggregate reporting, use `lifetime.total_phases` + current `total_phases` for the grand total across all milestones.
166
209
  - Backward compatibility: if `lifetime` is absent in tracking.json, treat all counters as 0 and `milestone` as 1.
167
210
 
@@ -175,6 +218,8 @@ Authorization: Bearer <api-key>
175
218
  | submitted_by | string | yes | Team member name |
176
219
  | submitted_at | string | yes | ISO 8601 timestamp |
177
220
  | milestone | number | recommended | Current milestone number (1-indexed) |
221
+ | milestone_name | string | recommended (v4+) | Human name of the current milestone — from JOURNEY.md / tracking.json |
222
+ | milestones | array | recommended (v4+) | Array of closed milestone summaries: `{num, name, closed_at, phases_completed, tasks_completed}`. Renders the journey tree on the ERP. |
178
223
  | lifetime | object | recommended | Cumulative counters — tasks_completed, phases_completed, milestones_completed, total_phases, last_closed_milestone |
179
224
  | project_id | string | recommended (v3.6+) | Stable per-project identifier — preferred dedupe key over `project` slug. Survives directory renames. |
180
225
  | team_id | string | recommended (v3.6+) | Installation's team identifier. Composite `(team_id, project_id)` is the canonical project key. |
@@ -183,6 +228,8 @@ Authorization: Bearer <api-key>
183
228
  | last_pushed_at | string | optional (v3.6+) | ISO 8601 — distinct from `last_updated` (which fires on local writes too). |
184
229
  | build_count | number | optional (v3.6+) | Lifetime build counter. |
185
230
  | deploy_count | number | optional (v3.6+) | Lifetime deploy counter. |
231
+ | client_report_id | string | recommended (v4.0.4+) | Client-side sequential identifier: `QS-REPORT-01`, `QS-REPORT-02`, … per-project. Stable across retries. Preferred dedupe key over the ERP-generated `report_id`; safe to adopt as the ERP's primary report key. |
232
+ | dry_run | boolean | optional (v4.0.4+) | `true` marks a synthetic ping (from `qualia-framework erp-ping`). Receivers should filter these out of production report views. |
186
233
 
187
234
  All other fields are optional but recommended for complete reporting.
188
235
 
package/guide.md CHANGED
@@ -43,7 +43,7 @@ Append `--auto` to `/qualia-new` and the framework chains every step:
43
43
 
44
44
  **Plus one halt case:** if a phase fails verification beyond the gap-cycle limit (default 2), the chain stops and asks for human intervention.
45
45
 
46
- ## The 10 Commands
46
+ ## The Road Commands
47
47
 
48
48
  | When | Command | What it does |
49
49
  |------|---------|-------------|
@@ -11,6 +11,10 @@ const _traceStart = Date.now();
11
11
  // Read JSON tool input from stdin with a safety timeout.
12
12
  // On Windows, fs.readFileSync(0) can hang if stdin isn't closed by the host.
13
13
  // We loop fs.readSync with a 1s deadline; if no data arrives, treat as empty.
14
+ // Between EAGAIN retries we sleep via Atomics.wait to avoid CPU-burning spin.
15
+ function sleepSync(ms) {
16
+ try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch {}
17
+ }
14
18
  function readInput() {
15
19
  const deadline = Date.now() + 1000;
16
20
  const buf = Buffer.alloc(65536);
@@ -21,8 +25,8 @@ function readInput() {
21
25
  try {
22
26
  n = fs.readSync(0, buf, 0, buf.length, null);
23
27
  } catch (e) {
24
- // EAGAIN/EWOULDBLOCK: no data yet, retry until deadline
25
- if (e && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) continue;
28
+ // EAGAIN/EWOULDBLOCK: no data yet. Sleep 1ms and retry until deadline.
29
+ if (e && (e.code === "EAGAIN" || e.code === "EWOULDBLOCK")) { sleepSync(1); continue; }
26
30
  // Any other read error: bail
27
31
  break;
28
32
  }
@@ -108,14 +112,24 @@ if (/ALTER\s+TABLE\s+[^;]*\bDROP\s+COLUMN\b/i.test(scan)) {
108
112
  errors.push("ALTER TABLE ... DROP COLUMN is destructive");
109
113
  }
110
114
 
111
- // DELETE without WHERE
112
- if (/DELETE\s+FROM/i.test(scan) && !/WHERE/i.test(scan)) {
113
- errors.push("DELETE FROM without WHERE clause");
115
+ // DELETE / UPDATE without WHERE — check per-statement, not file-global.
116
+ // Previously a file containing "DELETE FROM foo;" followed by any later
117
+ // "... WHERE ..." (in a SELECT, JOIN, etc.) would pass the check.
118
+ function splitStatements(src) {
119
+ return src.split(/;/g).map((s) => s.trim()).filter(Boolean);
114
120
  }
115
-
116
- // UPDATE without WHERE affects every row
117
- if (/\bUPDATE\s+\w+(?:\.\w+)?\s+SET\b/i.test(scan) && !/WHERE/i.test(scan)) {
118
- errors.push("UPDATE without WHERE clause — affects every row");
121
+ const statements = splitStatements(scan);
122
+ for (const stmt of statements) {
123
+ if (/^\s*DELETE\s+FROM\b/i.test(stmt) && !/\bWHERE\b/i.test(stmt)) {
124
+ errors.push("DELETE FROM without WHERE clause");
125
+ break;
126
+ }
127
+ }
128
+ for (const stmt of statements) {
129
+ if (/^\s*UPDATE\s+\w+(?:\.\w+)?\s+SET\b/i.test(stmt) && !/\bWHERE\b/i.test(stmt)) {
130
+ errors.push("UPDATE without WHERE clause — affects every row");
131
+ break;
132
+ }
119
133
  }
120
134
 
121
135
  // TRUNCATE (almost always wrong in migrations)
@@ -2,17 +2,44 @@
2
2
  // ~/.claude/hooks/pre-compact.js — commit STATE.md before context compaction.
3
3
  // PreCompact hook. Silent on failure — context compaction must never be blocked.
4
4
  // Cross-platform (Windows/macOS/Linux).
5
+ //
6
+ // BY DEFAULT this commit uses --no-verify + --no-gpg-sign. The auto-save is a
7
+ // framework bot commit, and pre-commit hooks that run full test suites would
8
+ // routinely fail (context compaction happens at any moment) and lose the
9
+ // STATE.md snapshot. But compliance-sensitive projects can opt into strict
10
+ // mode via ~/.claude/.qualia-config.json:
11
+ //
12
+ // {
13
+ // "pre_compact": {
14
+ // "respect_user_hooks": true,
15
+ // "respect_gpg_signing": true
16
+ // }
17
+ // }
18
+ //
19
+ // When either is true, the corresponding --no-* flag is dropped.
5
20
 
6
21
  const fs = require("fs");
7
22
  const path = require("path");
23
+ const os = require("os");
8
24
  const { spawnSync } = require("child_process");
9
25
 
10
26
  const _traceStart = Date.now();
11
27
 
12
28
  const STATE_FILE = path.join(".planning", "STATE.md");
29
+ const CONFIG_FILE = path.join(os.homedir(), ".claude", ".qualia-config.json");
30
+
31
+ function readCompactConfig() {
32
+ try {
33
+ const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
34
+ return cfg.pre_compact || {};
35
+ } catch {
36
+ return {};
37
+ }
38
+ }
13
39
 
14
40
  let _commitStatus = null;
15
41
  let _commitReason = "no-state-file";
42
+ let _commitFlags = null;
16
43
 
17
44
  try {
18
45
  if (fs.existsSync(STATE_FILE)) {
@@ -29,16 +56,17 @@ try {
29
56
  timeout: 3000,
30
57
  shell: process.platform === "win32",
31
58
  });
32
- // Bypass user pre-commit hooks and commit signing so the auto-save
33
- // never fails silently and STATE.md is always persisted before
34
- // context compaction. Attribute to the framework bot, not the user.
35
- const commitRes = spawnSync("git", [
36
- "commit",
37
- "--no-verify",
38
- "--no-gpg-sign",
39
- "--author=Qualia Framework <bot@qualia.solutions>",
40
- "-m", "state: pre-compaction save",
41
- ], {
59
+ const cfg = readCompactConfig();
60
+ const commitArgs = ["commit"];
61
+ if (!cfg.respect_user_hooks) commitArgs.push("--no-verify");
62
+ if (!cfg.respect_gpg_signing) commitArgs.push("--no-gpg-sign");
63
+ commitArgs.push("--author=Qualia Framework <bot@qualia.solutions>");
64
+ commitArgs.push("-m", "state: pre-compaction save");
65
+ _commitFlags = {
66
+ no_verify: !cfg.respect_user_hooks,
67
+ no_gpg_sign: !cfg.respect_gpg_signing,
68
+ };
69
+ const commitRes = spawnSync("git", commitArgs, {
42
70
  timeout: 5000,
43
71
  stdio: ["ignore", "ignore", "pipe"],
44
72
  encoding: "utf8",
@@ -72,5 +100,5 @@ function _trace(hookName, result, extra) {
72
100
  } catch {}
73
101
  }
74
102
 
75
- _trace("pre-compact", "allow", { commit_status: _commitStatus, commit_reason: _commitReason });
103
+ _trace("pre-compact", "allow", { commit_status: _commitStatus, commit_reason: _commitReason, commit_flags: _commitFlags });
76
104
  process.exit(0);
@@ -2,8 +2,7 @@
2
2
  // ~/.claude/hooks/pre-deploy-gate.js — quality gates before production deploy.
3
3
  // PreToolUse hook on `vercel --prod*` commands. Runs tsc, lint, tests, build,
4
4
  // then scans for service_role leaks in client code.
5
- // Exits 1 to BLOCK deploy (preserved for test compatibility; Claude Code's hook
6
- // protocol formally uses exit 2, but the framework's existing tests assert on 1).
5
+ // Exits 2 to BLOCK deploy (Claude Code PreToolUse hook contract).
7
6
  // Exits 0 to allow.
8
7
  // Cross-platform (Windows/macOS/Linux). No `grep` or `find` — pure Node.
9
8
 
@@ -43,7 +42,7 @@ function runGate(label, cmd, args, { required = true } = {}) {
43
42
  if (required) {
44
43
  console.error(`BLOCKED: ${label} errors. Fix before deploying.`);
45
44
  _trace("pre-deploy-gate", "block", { gate: label });
46
- process.exit(1);
45
+ process.exit(2);
47
46
  }
48
47
  return false;
49
48
  }
@@ -180,7 +179,7 @@ if (leaks.length > 0) {
180
179
  console.error(` ✗ ${f}`);
181
180
  }
182
181
  _trace("pre-deploy-gate", "block", { gate: "security", leaks: leaks.slice(0, 10) });
183
- process.exit(1);
182
+ process.exit(2);
184
183
  }
185
184
  console.log(" ✓ Security");
186
185
  console.log("⬢ All gates passed.");
package/hooks/pre-push.js CHANGED
@@ -85,9 +85,12 @@ function commitStamp() {
85
85
  "-m", `chore(track): ERP sync ${now}`,
86
86
  ]);
87
87
  if (commit.status !== 0) {
88
- // If commit failed (e.g., empty diff because git's auto-CRLF normalized
89
- // the only change to nothing), restore the file to keep the working tree
90
- // clean and move on. Not fatal.
88
+ // Commit failed (e.g., empty diff because git's auto-CRLF normalized the
89
+ // only change to nothing, or branch is in a detached/conflicted state).
90
+ // Unstage tracking.json and restore the working tree copy so the user's
91
+ // next manual commit isn't polluted by our aborted stamp.
92
+ try { git(["reset", "HEAD", "--", TRACKING]); } catch {}
93
+ try { if (raw != null) atomicWrite(TRACKING, raw); } catch {}
91
94
  return { skipped: "git-commit-failed", error: (commit.stderr || commit.stdout || "").trim() };
92
95
  }
93
96
  return { committed: true, sha: lastCommit, ts: now };