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.
- package/CLAUDE.md +23 -11
- package/agents/plan-checker.md +1 -1
- package/agents/roadmapper.md +10 -5
- package/bin/cli.js +139 -17
- package/bin/install.js +47 -47
- package/bin/qualia-ui.js +2 -2
- package/bin/state.js +126 -9
- package/bin/statusline.js +63 -38
- package/docs/erp-contract.md +49 -2
- package/guide.md +1 -1
- package/hooks/migration-guard.js +23 -9
- package/hooks/pre-compact.js +39 -11
- package/hooks/pre-deploy-gate.js +3 -4
- package/hooks/pre-push.js +6 -3
- package/hooks/session-start.js +8 -8
- package/package.json +1 -1
- package/rules/frontend.md +5 -13
- package/skills/qualia/SKILL.md +5 -0
- package/skills/qualia-build/SKILL.md +10 -0
- package/skills/qualia-debug/SKILL.md +6 -0
- package/skills/qualia-design/SKILL.md +9 -1
- package/skills/qualia-discuss/SKILL.md +6 -0
- package/skills/qualia-handoff/SKILL.md +5 -0
- package/skills/qualia-help/SKILL.md +18 -4
- package/skills/qualia-idk/SKILL.md +6 -0
- package/skills/qualia-learn/SKILL.md +6 -0
- package/skills/qualia-map/SKILL.md +7 -0
- package/skills/qualia-milestone/SKILL.md +6 -0
- package/skills/qualia-new/SKILL.md +31 -4
- package/skills/qualia-optimize/SKILL.md +8 -0
- package/skills/qualia-pause/SKILL.md +5 -0
- package/skills/qualia-plan/SKILL.md +11 -1
- package/skills/qualia-polish/SKILL.md +8 -0
- package/skills/qualia-quick/SKILL.md +7 -0
- package/skills/qualia-report/SKILL.md +146 -60
- package/skills/qualia-research/SKILL.md +7 -0
- package/skills/qualia-resume/SKILL.md +3 -0
- package/skills/qualia-review/SKILL.md +7 -0
- package/skills/qualia-ship/SKILL.md +5 -0
- package/skills/qualia-skill-new/SKILL.md +6 -0
- package/skills/qualia-task/SKILL.md +8 -1
- package/skills/qualia-test/SKILL.md +7 -0
- package/skills/qualia-verify/SKILL.md +8 -0
- package/templates/help.html +4 -4
- package/templates/tracking.json +1 -0
- package/tests/hooks.test.sh +5 -5
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
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
|
-
// ───
|
|
187
|
-
|
|
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
|
|
190
|
-
if (fs.existsSync(
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (
|
|
253
|
-
LINE1 += ` ${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}`;
|
package/docs/erp-contract.md
CHANGED
|
@@ -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": "
|
|
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
|
|
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
|
|
46
|
+
## The Road Commands
|
|
47
47
|
|
|
48
48
|
| When | Command | What it does |
|
|
49
49
|
|------|---------|-------------|
|
package/hooks/migration-guard.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
if (
|
|
118
|
-
|
|
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)
|
package/hooks/pre-compact.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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);
|
package/hooks/pre-deploy-gate.js
CHANGED
|
@@ -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
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
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 };
|