qualia-framework 4.3.0 → 4.4.0
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/README.md +15 -11
- package/bin/agent-runs.js +233 -0
- package/bin/cli.js +218 -19
- package/bin/install.js +19 -5
- package/bin/plan-contract.js +220 -0
- package/bin/state.js +15 -9
- package/docs/agent-runs.md +273 -0
- package/docs/plan-contract.md +321 -0
- package/hooks/auto-update.js +3 -7
- package/hooks/pre-compact.js +22 -11
- package/hooks/pre-deploy-gate.js +16 -2
- package/hooks/pre-push.js +22 -2
- package/hooks/stop-session-log.js +1 -1
- package/package.json +8 -2
- package/skills/qualia-build/SKILL.md +5 -5
- package/skills/qualia-flush/SKILL.md +1 -1
- package/skills/qualia-quick/SKILL.md +1 -1
- package/skills/qualia-report/SKILL.md +1 -1
- package/skills/qualia-ship/SKILL.md +12 -10
- package/templates/help.html +13 -7
- package/tests/bin.test.sh +6 -3
- package/tests/hooks.test.sh +9 -20
- package/tests/lib.test.sh +217 -0
- package/tests/runner.js +96 -75
- package/tests/state.test.sh +4 -3
package/README.md
CHANGED
|
@@ -115,13 +115,13 @@ Project
|
|
|
115
115
|
|
|
116
116
|
**Why it matters:** non-technical team members can follow the ladder from any entry point. `/qualia` and `/qualia-milestone` render JOURNEY.md as a visual ladder with current position highlighted.
|
|
117
117
|
|
|
118
|
-
## What's Inside (v4.
|
|
118
|
+
## What's Inside (v4.3.0)
|
|
119
119
|
|
|
120
|
-
- **
|
|
120
|
+
- **28 skills** — from setup to handoff, plus debug, design, review, optimize, diagnostic (`qualia-idk`), memory flush, postmortem, session management, skill authoring, per-phase depth (discuss, research, map), and full-journey additions (`--auto` chaining, milestone closure)
|
|
121
121
|
- **8 agents** (each runs in fresh context): planner, builder, verifier, qa-browser, researcher, research-synthesizer, roadmapper, plan-checker
|
|
122
|
-
- **
|
|
123
|
-
- **
|
|
124
|
-
- **
|
|
122
|
+
- **9 hooks** (pure Node.js, cross-platform): session-start, auto-update, git-guardrails, branch-guard, pre-push tracking sync, migration-guard, pre-deploy-gate, pre-compact state save, stop-session-log
|
|
123
|
+
- **6 rules**: security, frontend, design-reference, deployment, infrastructure, grounding
|
|
124
|
+
- **21 template files**: project.md, **journey.md** (new in v4), plan.md (story-file format), state.md, DESIGN.md, tracking.json (now with `milestone_name` + `milestones[]`), requirements.md (multi-milestone), roadmap.md (current milestone only), phase-context.md, 4 project-type templates (website, ai-agent, voice-agent, mobile-app), 5 research-project templates (STACK, FEATURES, ARCHITECTURE, PITFALLS, SUMMARY), knowledge templates, help.html
|
|
125
125
|
- **1 reference** — questioning.md methodology for deep project initialization
|
|
126
126
|
|
|
127
127
|
## Supported Platforms
|
|
@@ -156,13 +156,17 @@ Splitting planner, builder, and verifier into separate agents with separate cont
|
|
|
156
156
|
|
|
157
157
|
### Production-Grade Hooks
|
|
158
158
|
|
|
159
|
-
All
|
|
159
|
+
All 9 hooks are real ops engineering, not theoretical:
|
|
160
160
|
|
|
161
161
|
- **Pre-deploy gate** — TypeScript, lint, tests, build, and `service_role` leak scan before `vercel --prod`
|
|
162
|
+
- **Session start** — Shows project state, next command, update notices, and health warnings at session start
|
|
163
|
+
- **Auto-update** — Daily update check with cached failures so offline/npm issues do not slow every command
|
|
164
|
+
- **Git guardrails** — Blocks destructive git operations like force-push to main/master, `git clean -fd`, and `rm -rf .git`
|
|
162
165
|
- **Branch guard** — Role-aware: owner can push to main, employees can't (parses refspec so `feature/x:main` bypass is blocked)
|
|
163
166
|
- **Migration guard** — Catches `DROP TABLE` without `IF EXISTS`, `DELETE`/`UPDATE` without `WHERE`, `CREATE TABLE` without RLS, `GRANT ... TO PUBLIC`, `ALTER TABLE ... DROP COLUMN`
|
|
164
167
|
- **Pre-push** — Stamps tracking.json via a bot commit so the ERP always sees fresh data
|
|
165
168
|
- **Pre-compact** — Saves state before context compression
|
|
169
|
+
- **Stop-session log** — Writes lightweight daily session checkpoints into the knowledge layer
|
|
166
170
|
|
|
167
171
|
### Enforced State Machine
|
|
168
172
|
|
|
@@ -183,12 +187,12 @@ npx qualia-framework@latest install
|
|
|
183
187
|
|
|
|
184
188
|
v
|
|
185
189
|
~/.claude/
|
|
186
|
-
├── skills/
|
|
190
|
+
├── skills/ 28 slash commands
|
|
187
191
|
├── agents/ 8 agent definitions (planner, builder, verifier, qa-browser, roadmapper, research-synthesizer, researcher, plan-checker)
|
|
188
|
-
├── hooks/
|
|
189
|
-
├── bin/ state.js
|
|
192
|
+
├── hooks/ 9 Node.js hooks — cross-platform (no bash dependency)
|
|
193
|
+
├── bin/ state.js + qualia-ui.js + statusline.js + knowledge.js + knowledge-flush.js
|
|
190
194
|
├── knowledge/ learned-patterns.md, common-fixes.md, client-prefs.md
|
|
191
|
-
├── rules/ security, frontend, design-reference, deployment, infrastructure
|
|
195
|
+
├── rules/ security, frontend, design-reference, deployment, infrastructure, grounding
|
|
192
196
|
├── qualia-templates/ project.md, journey.md, plan.md (story-file), state.md, DESIGN.md, tracking.json, requirements.md, roadmap.md, + projects/*.md + research-project/*.md + help.html
|
|
193
197
|
├── qualia-references/ questioning.md (deep project initialization methodology)
|
|
194
198
|
├── CLAUDE.md global instructions (role-configured per team member)
|
|
@@ -201,6 +205,6 @@ Stack: Next.js 16+, React 19, TypeScript, Supabase, Vercel. Voice: Retell AI, El
|
|
|
201
205
|
|
|
202
206
|
## Changelog
|
|
203
207
|
|
|
204
|
-
See [CHANGELOG.md](./CHANGELOG.md) for the full version history. v4.
|
|
208
|
+
See [CHANGELOG.md](./CHANGELOG.md) for the full version history. v4.3.0 release notes are the most recent section.
|
|
205
209
|
|
|
206
210
|
Built by [Qualia Solutions](https://qualiasolutions.net) — Nicosia, Cyprus.
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Agent runs telemetry — JSONL writer + reader. See docs/agent-runs.md.
|
|
3
|
+
//
|
|
4
|
+
// Pure library. Atomic writes via fs.appendFileSync (single write() syscall
|
|
5
|
+
// to an O_APPEND file descriptor; safe at our record sizes — see the spec).
|
|
6
|
+
//
|
|
7
|
+
// Zero npm dependencies.
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const path = require("path");
|
|
11
|
+
const crypto = require("crypto");
|
|
12
|
+
|
|
13
|
+
const SCHEMA_VERSION = 1;
|
|
14
|
+
|
|
15
|
+
const VALID_AGENT_TYPES = new Set([
|
|
16
|
+
"planner", "plan-checker", "builder", "verifier", "qa-browser",
|
|
17
|
+
"researcher", "research-synthesizer", "roadmapper", "team-orchestrator",
|
|
18
|
+
"custom",
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const VALID_STATUS = new Set([
|
|
22
|
+
"success", "partial", "blocked", "failure", "timeout", "interrupted",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
// One UUID per process — fallback when Claude Code doesn't expose a session id.
|
|
26
|
+
let _processSessionId = null;
|
|
27
|
+
function processSessionId() {
|
|
28
|
+
if (!_processSessionId) {
|
|
29
|
+
const buf = crypto.randomBytes(16);
|
|
30
|
+
// RFC 4122 v4
|
|
31
|
+
buf[6] = (buf[6] & 0x0f) | 0x40;
|
|
32
|
+
buf[8] = (buf[8] & 0x3f) | 0x80;
|
|
33
|
+
const h = buf.toString("hex");
|
|
34
|
+
_processSessionId = `${h.slice(0,8)}-${h.slice(8,12)}-${h.slice(12,16)}-${h.slice(16,20)}-${h.slice(20)}`;
|
|
35
|
+
}
|
|
36
|
+
return _processSessionId;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ULID-ish: timestamp prefix + random suffix. Sortable by time.
|
|
40
|
+
function newRunId() {
|
|
41
|
+
const ts = Date.now().toString(36).toUpperCase().padStart(10, "0");
|
|
42
|
+
const rand = crypto.randomBytes(10).toString("hex").toUpperCase();
|
|
43
|
+
return `${ts}${rand}`.slice(0, 26);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function planningDir(cwd) {
|
|
47
|
+
return path.join(cwd || process.cwd(), ".planning");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function jsonlPath(cwd) {
|
|
51
|
+
return path.join(planningDir(cwd), "agent-runs.jsonl");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function logDir(cwd) {
|
|
55
|
+
return path.join(planningDir(cwd), "agent-runs");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function telemetryEnabled() {
|
|
59
|
+
return (process.env.QUALIA_TELEMETRY || "").toLowerCase() !== "off";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function ensureDir(p) {
|
|
63
|
+
if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function truncTail(s, max) {
|
|
67
|
+
if (typeof s !== "string") return undefined;
|
|
68
|
+
if (s.length <= max) return s;
|
|
69
|
+
return s.slice(s.length - max);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Writer ────────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
// start({ agent_type, model, ... }) → opaque token used by finish()
|
|
75
|
+
function start(opts) {
|
|
76
|
+
const now = new Date().toISOString();
|
|
77
|
+
const token = {
|
|
78
|
+
started_at: now,
|
|
79
|
+
started_ms: Date.now(),
|
|
80
|
+
record: {
|
|
81
|
+
schema_version: SCHEMA_VERSION,
|
|
82
|
+
run_id: opts.run_id || newRunId(),
|
|
83
|
+
parent_run_id: opts.parent_run_id || undefined,
|
|
84
|
+
skill_invocation_id: opts.skill_invocation_id || processSessionId(),
|
|
85
|
+
session_id: opts.session_id || processSessionId(),
|
|
86
|
+
agent_type: opts.agent_type,
|
|
87
|
+
agent_name: opts.agent_name || undefined,
|
|
88
|
+
model: opts.model,
|
|
89
|
+
effort: opts.effort || undefined,
|
|
90
|
+
project: opts.project || undefined,
|
|
91
|
+
phase: opts.phase != null ? opts.phase : undefined,
|
|
92
|
+
milestone: opts.milestone != null ? opts.milestone : undefined,
|
|
93
|
+
task_id: opts.task_id || undefined,
|
|
94
|
+
wave: opts.wave != null ? opts.wave : undefined,
|
|
95
|
+
retry_of: opts.retry_of || undefined,
|
|
96
|
+
started_at: now,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
return token;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// finish(token, { status, ... }) → writes the JSONL line + optional log file
|
|
103
|
+
function finish(token, result) {
|
|
104
|
+
if (!token || !token.record) throw new Error("finish: invalid token");
|
|
105
|
+
if (!telemetryEnabled()) return { written: false, reason: "telemetry-off" };
|
|
106
|
+
|
|
107
|
+
const cwd = result.cwd || process.cwd();
|
|
108
|
+
if (!fs.existsSync(planningDir(cwd))) {
|
|
109
|
+
return { written: false, reason: "no-planning-dir" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const finishedMs = Date.now();
|
|
113
|
+
const record = {
|
|
114
|
+
...token.record,
|
|
115
|
+
status: result.status,
|
|
116
|
+
started_at: token.record.started_at,
|
|
117
|
+
finished_at: new Date(finishedMs).toISOString(),
|
|
118
|
+
duration_ms: finishedMs - token.started_ms,
|
|
119
|
+
input_tokens: result.input_tokens,
|
|
120
|
+
output_tokens: result.output_tokens,
|
|
121
|
+
cache_read_tokens: result.cache_read_tokens,
|
|
122
|
+
cache_creation_tokens: result.cache_creation_tokens,
|
|
123
|
+
tool_calls_count: result.tool_calls_count,
|
|
124
|
+
files_changed: Array.isArray(result.files_changed) ? [...new Set(result.files_changed)] : undefined,
|
|
125
|
+
commit_sha: result.commit_sha || undefined,
|
|
126
|
+
verifier_score: result.verifier_score,
|
|
127
|
+
verification_result: result.verification_result,
|
|
128
|
+
failure_reason: result.failure_reason,
|
|
129
|
+
failure_detail: truncTail(result.failure_detail, 500),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
if (!VALID_AGENT_TYPES.has(record.agent_type)) {
|
|
133
|
+
record.failure_reason = record.failure_reason || "unknown";
|
|
134
|
+
// don't reject — we want the trace even if the caller misnamed itself
|
|
135
|
+
}
|
|
136
|
+
if (!VALID_STATUS.has(record.status)) {
|
|
137
|
+
record.status = "failure";
|
|
138
|
+
record.failure_reason = record.failure_reason || "unknown";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Side log for non-success runs.
|
|
142
|
+
if (record.status !== "success" && typeof result.full_stderr === "string" && result.full_stderr.length) {
|
|
143
|
+
try {
|
|
144
|
+
ensureDir(logDir(cwd));
|
|
145
|
+
const logFile = path.join(logDir(cwd), `${record.run_id}.log`);
|
|
146
|
+
fs.writeFileSync(logFile, result.full_stderr);
|
|
147
|
+
record.log_file = path.relative(cwd, logFile).split(path.sep).join("/");
|
|
148
|
+
} catch {
|
|
149
|
+
// Side-log is best-effort — never block the JSONL write.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Drop undefined keys for a compact line.
|
|
154
|
+
const clean = {};
|
|
155
|
+
for (const [k, v] of Object.entries(record)) if (v !== undefined) clean[k] = v;
|
|
156
|
+
|
|
157
|
+
const line = JSON.stringify(clean) + "\n";
|
|
158
|
+
ensureDir(planningDir(cwd));
|
|
159
|
+
fs.appendFileSync(jsonlPath(cwd), line);
|
|
160
|
+
return { written: true, run_id: record.run_id, log_file: record.log_file };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Reader ────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
function read(cwd, opts) {
|
|
166
|
+
const file = jsonlPath(cwd);
|
|
167
|
+
if (!fs.existsSync(file)) return [];
|
|
168
|
+
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean);
|
|
169
|
+
const records = [];
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
try { records.push(JSON.parse(line)); }
|
|
172
|
+
catch { /* skip corrupt line; we never want a single bad record to mask the rest */ }
|
|
173
|
+
}
|
|
174
|
+
let out = records;
|
|
175
|
+
if (opts && opts.failed) {
|
|
176
|
+
out = out.filter((r) => r.status !== "success");
|
|
177
|
+
}
|
|
178
|
+
if (opts && opts.task_id) {
|
|
179
|
+
out = out.filter((r) => r.task_id === opts.task_id);
|
|
180
|
+
}
|
|
181
|
+
if (opts && opts.phase != null) {
|
|
182
|
+
out = out.filter((r) => r.phase === opts.phase);
|
|
183
|
+
}
|
|
184
|
+
if (opts && opts.limit) {
|
|
185
|
+
out = out.slice(-opts.limit);
|
|
186
|
+
}
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function prune(cwd, beforeIso) {
|
|
191
|
+
const file = jsonlPath(cwd);
|
|
192
|
+
if (!fs.existsSync(file)) return { removed: 0, logs_removed: 0 };
|
|
193
|
+
const cutoff = Date.parse(beforeIso);
|
|
194
|
+
if (!Number.isFinite(cutoff)) throw new Error(`prune: invalid date "${beforeIso}"`);
|
|
195
|
+
|
|
196
|
+
const lines = fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean);
|
|
197
|
+
const kept = [];
|
|
198
|
+
const removedRunIds = [];
|
|
199
|
+
for (const line of lines) {
|
|
200
|
+
let rec;
|
|
201
|
+
try { rec = JSON.parse(line); }
|
|
202
|
+
catch { kept.push(line); continue; } // preserve unparseable; never destroy data we don't understand
|
|
203
|
+
const ts = Date.parse(rec.finished_at || rec.started_at || "");
|
|
204
|
+
if (Number.isFinite(ts) && ts < cutoff) {
|
|
205
|
+
removedRunIds.push(rec.run_id);
|
|
206
|
+
} else {
|
|
207
|
+
kept.push(line);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
fs.writeFileSync(file, kept.join("\n") + (kept.length ? "\n" : ""));
|
|
211
|
+
|
|
212
|
+
let logsRemoved = 0;
|
|
213
|
+
if (fs.existsSync(logDir(cwd))) {
|
|
214
|
+
for (const id of removedRunIds) {
|
|
215
|
+
const lf = path.join(logDir(cwd), `${id}.log`);
|
|
216
|
+
try { fs.unlinkSync(lf); logsRemoved++; } catch {}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { removed: removedRunIds.length, logs_removed: logsRemoved };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = {
|
|
223
|
+
SCHEMA_VERSION,
|
|
224
|
+
start,
|
|
225
|
+
finish,
|
|
226
|
+
read,
|
|
227
|
+
prune,
|
|
228
|
+
// exposed for tests / introspection
|
|
229
|
+
newRunId,
|
|
230
|
+
processSessionId,
|
|
231
|
+
jsonlPath,
|
|
232
|
+
logDir,
|
|
233
|
+
};
|
package/bin/cli.js
CHANGED
|
@@ -29,7 +29,8 @@ function readConfig() {
|
|
|
29
29
|
|
|
30
30
|
function writeConfig(cfg) {
|
|
31
31
|
if (!fs.existsSync(CLAUDE_DIR)) fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
32
|
-
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
|
|
32
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
33
|
+
try { fs.chmodSync(CONFIG_FILE, 0o600); } catch {}
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
function banner() {
|
|
@@ -159,10 +160,10 @@ const QUALIA_AGENT_FILES = [
|
|
|
159
160
|
];
|
|
160
161
|
|
|
161
162
|
// 3 Qualia bin scripts.
|
|
162
|
-
const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js", "knowledge.js", "knowledge-flush.js"];
|
|
163
|
+
const QUALIA_BIN_FILES = ["state.js", "qualia-ui.js", "statusline.js", "knowledge.js", "knowledge-flush.js", "plan-contract.js", "agent-runs.js"];
|
|
163
164
|
|
|
164
|
-
//
|
|
165
|
-
const QUALIA_RULE_FILES = ["security.md", "frontend.md", "design-reference.md", "deployment.md", "infrastructure.md"];
|
|
165
|
+
// 6 Qualia rules.
|
|
166
|
+
const QUALIA_RULE_FILES = ["security.md", "frontend.md", "design-reference.md", "deployment.md", "infrastructure.md", "grounding.md"];
|
|
166
167
|
|
|
167
168
|
function promptYesNo(question, defaultYes) {
|
|
168
169
|
return new Promise((resolve) => {
|
|
@@ -553,7 +554,7 @@ function cmdMigrate() {
|
|
|
553
554
|
console.log(` ${DIM}Target version:${RESET} ${WHITE}${PKG.version}${RESET}`);
|
|
554
555
|
console.log("");
|
|
555
556
|
|
|
556
|
-
// 1. Ensure
|
|
557
|
+
// 1. Ensure the full v4.3 hook set is wired.
|
|
557
558
|
const hd = path.join(CLAUDE_DIR, "hooks");
|
|
558
559
|
const nodeCmd = (hookFile) => `node "${path.join(hd, hookFile)}"`;
|
|
559
560
|
|
|
@@ -564,10 +565,19 @@ function cmdMigrate() {
|
|
|
564
565
|
settings.hooks.SessionStart = [{ matcher: ".*", hooks: [{ type: "command", command: nodeCmd("session-start.js"), timeout: 5 }] }];
|
|
565
566
|
changes++;
|
|
566
567
|
console.log(` ${GREEN}+${RESET} Added SessionStart hook`);
|
|
568
|
+
} else {
|
|
569
|
+
const hasSessionStart = settings.hooks.SessionStart.some(e =>
|
|
570
|
+
Array.isArray(e.hooks) && e.hooks.some(h => typeof h.command === "string" && h.command.includes("session-start.js"))
|
|
571
|
+
);
|
|
572
|
+
if (!hasSessionStart) {
|
|
573
|
+
settings.hooks.SessionStart.push({ matcher: ".*", hooks: [{ type: "command", command: nodeCmd("session-start.js"), timeout: 5 }] });
|
|
574
|
+
changes++;
|
|
575
|
+
console.log(` ${GREEN}+${RESET} Wired session-start.js into SessionStart`);
|
|
576
|
+
}
|
|
567
577
|
}
|
|
568
578
|
|
|
569
579
|
// Check PreToolUse hooks — ensure all critical hooks are present
|
|
570
|
-
const requiredBashHooks = ["auto-update.js", "branch-guard.js", "pre-push.js", "pre-deploy-gate.js"];
|
|
580
|
+
const requiredBashHooks = ["auto-update.js", "git-guardrails.js", "branch-guard.js", "pre-push.js", "pre-deploy-gate.js"];
|
|
571
581
|
const requiredEditHooks = ["migration-guard.js"];
|
|
572
582
|
|
|
573
583
|
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
|
|
@@ -595,9 +605,10 @@ function cmdMigrate() {
|
|
|
595
605
|
const exists = bashEntry.hooks.some(h => extractScriptName(h.command) === targetName);
|
|
596
606
|
if (!exists) {
|
|
597
607
|
const hookDef = { type: "command", command: cmd, timeout: hookFile === "pre-deploy-gate.js" ? 180 : 5 };
|
|
598
|
-
if (hookFile === "
|
|
608
|
+
if (hookFile === "git-guardrails.js") hookDef.statusMessage = "⬢ Checking git safety...";
|
|
609
|
+
if (hookFile === "branch-guard.js") { hookDef.if = "Bash(git push*)"; hookDef.statusMessage = "⬢ Checking branch permissions..."; }
|
|
599
610
|
if (hookFile === "pre-push.js") { hookDef.if = "Bash(git push*)"; hookDef.timeout = 15; }
|
|
600
|
-
if (hookFile === "pre-deploy-gate.js") hookDef.if = "Bash(vercel --prod*)";
|
|
611
|
+
if (hookFile === "pre-deploy-gate.js") { hookDef.if = "Bash(vercel --prod*)"; hookDef.timeout = 180; hookDef.statusMessage = "⬢ Running quality gates..."; }
|
|
601
612
|
bashEntry.hooks.push(hookDef);
|
|
602
613
|
changes++;
|
|
603
614
|
console.log(` ${GREEN}+${RESET} Wired ${hookFile} into PreToolUse/Bash`);
|
|
@@ -618,7 +629,7 @@ function cmdMigrate() {
|
|
|
618
629
|
const exists = editEntry.hooks.some(h => extractScriptName(h.command) === targetName);
|
|
619
630
|
if (!exists) {
|
|
620
631
|
const hookDef = { type: "command", command: cmd, timeout: hookFile === "migration-guard.js" ? 10 : 5 };
|
|
621
|
-
if (hookFile === "migration-guard.js") hookDef.if = "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)";
|
|
632
|
+
if (hookFile === "migration-guard.js") { hookDef.if = "Edit(*migration*)|Write(*migration*)|Edit(*.sql)|Write(*.sql)"; hookDef.statusMessage = "⬢ Checking migration safety..."; }
|
|
622
633
|
editEntry.hooks.push(hookDef);
|
|
623
634
|
changes++;
|
|
624
635
|
console.log(` ${GREEN}+${RESET} Wired ${hookFile} into PreToolUse/Edit|Write`);
|
|
@@ -626,10 +637,42 @@ function cmdMigrate() {
|
|
|
626
637
|
}
|
|
627
638
|
|
|
628
639
|
// Check PreCompact hook
|
|
629
|
-
if (!settings.hooks.PreCompact) {
|
|
640
|
+
if (!settings.hooks.PreCompact || !Array.isArray(settings.hooks.PreCompact)) {
|
|
630
641
|
settings.hooks.PreCompact = [{ matcher: "compact", hooks: [{ type: "command", command: nodeCmd("pre-compact.js"), timeout: 15 }] }];
|
|
631
642
|
changes++;
|
|
632
643
|
console.log(` ${GREEN}+${RESET} Added PreCompact hook`);
|
|
644
|
+
} else {
|
|
645
|
+
let compactEntry = settings.hooks.PreCompact.find(e => e.matcher === "compact");
|
|
646
|
+
if (!compactEntry) {
|
|
647
|
+
compactEntry = { matcher: "compact", hooks: [] };
|
|
648
|
+
settings.hooks.PreCompact.push(compactEntry);
|
|
649
|
+
}
|
|
650
|
+
if (!compactEntry.hooks) compactEntry.hooks = [];
|
|
651
|
+
const exists = compactEntry.hooks.some(h => extractScriptName(h.command) === "pre-compact.js");
|
|
652
|
+
if (!exists) {
|
|
653
|
+
compactEntry.hooks.push({ type: "command", command: nodeCmd("pre-compact.js"), timeout: 15, statusMessage: "⬢ Saving state..." });
|
|
654
|
+
changes++;
|
|
655
|
+
console.log(` ${GREEN}+${RESET} Wired pre-compact.js into PreCompact`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
if (!settings.hooks.Stop || !Array.isArray(settings.hooks.Stop)) {
|
|
660
|
+
settings.hooks.Stop = [{ matcher: ".*", hooks: [{ type: "command", command: nodeCmd("stop-session-log.js"), timeout: 5 }] }];
|
|
661
|
+
changes++;
|
|
662
|
+
console.log(` ${GREEN}+${RESET} Added Stop hook`);
|
|
663
|
+
} else {
|
|
664
|
+
let stopEntry = settings.hooks.Stop.find(e => e.matcher === ".*");
|
|
665
|
+
if (!stopEntry) {
|
|
666
|
+
stopEntry = { matcher: ".*", hooks: [] };
|
|
667
|
+
settings.hooks.Stop.push(stopEntry);
|
|
668
|
+
}
|
|
669
|
+
if (!stopEntry.hooks) stopEntry.hooks = [];
|
|
670
|
+
const exists = stopEntry.hooks.some(h => extractScriptName(h.command) === "stop-session-log.js");
|
|
671
|
+
if (!exists) {
|
|
672
|
+
stopEntry.hooks.push({ type: "command", command: nodeCmd("stop-session-log.js"), timeout: 5 });
|
|
673
|
+
changes++;
|
|
674
|
+
console.log(` ${GREEN}+${RESET} Wired stop-session-log.js into Stop`);
|
|
675
|
+
}
|
|
633
676
|
}
|
|
634
677
|
|
|
635
678
|
// 2. Ensure env vars are up to date
|
|
@@ -730,12 +773,12 @@ function cmdAnalytics() {
|
|
|
730
773
|
}
|
|
731
774
|
|
|
732
775
|
// Verification outcomes (from traces that include verification data)
|
|
733
|
-
const verifications = traces.filter(t => t.hook === "state-transition" && t.
|
|
734
|
-
const passes = verifications.filter(t => t.
|
|
735
|
-
const fails = verifications.filter(t => t.
|
|
776
|
+
const verifications = traces.filter(t => t.hook === "state-transition" && t.verification);
|
|
777
|
+
const passes = verifications.filter(t => t.verification === "pass").length;
|
|
778
|
+
const fails = verifications.filter(t => t.verification === "fail").length;
|
|
736
779
|
|
|
737
780
|
// Gap cycle data
|
|
738
|
-
const gapTraces = traces.filter(t => t.hook === "state-transition" && t.
|
|
781
|
+
const gapTraces = traces.filter(t => t.hook === "state-transition" && t.gap_closure);
|
|
739
782
|
const totalGapCycles = gapTraces.length;
|
|
740
783
|
|
|
741
784
|
// Display
|
|
@@ -781,8 +824,9 @@ function cmdErpPing() {
|
|
|
781
824
|
console.log("");
|
|
782
825
|
|
|
783
826
|
const cfg = readConfig();
|
|
827
|
+
const args = new Set(process.argv.slice(3));
|
|
784
828
|
const erpUrl = (cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net";
|
|
785
|
-
|
|
829
|
+
let erpEnabled = !(cfg.erp && cfg.erp.enabled === false);
|
|
786
830
|
const keyFile = path.join(CLAUDE_DIR, ".erp-api-key");
|
|
787
831
|
|
|
788
832
|
console.log(` ${DIM}URL:${RESET} ${WHITE}${erpUrl}${RESET}`);
|
|
@@ -802,6 +846,19 @@ function cmdErpPing() {
|
|
|
802
846
|
console.log(` ${DIM}Key:${RESET} ${GREEN}present${RESET} ${DIM}(${apiKey.length} bytes)${RESET}`);
|
|
803
847
|
console.log("");
|
|
804
848
|
|
|
849
|
+
if (!erpEnabled && args.has("--enable")) {
|
|
850
|
+
cfg.erp = {
|
|
851
|
+
...(cfg.erp || {}),
|
|
852
|
+
enabled: true,
|
|
853
|
+
url: erpUrl,
|
|
854
|
+
api_key_file: ".erp-api-key",
|
|
855
|
+
};
|
|
856
|
+
writeConfig(cfg);
|
|
857
|
+
erpEnabled = true;
|
|
858
|
+
console.log(` ${GREEN}✓ ERP enabled in config.${RESET}`);
|
|
859
|
+
console.log("");
|
|
860
|
+
}
|
|
861
|
+
|
|
805
862
|
if (!erpEnabled) {
|
|
806
863
|
console.log(` ${YELLOW}ERP is disabled in config. Enable with:${RESET}`);
|
|
807
864
|
console.log(` ${DIM} qualia-framework erp-ping --enable${RESET}`);
|
|
@@ -874,6 +931,61 @@ function cmdErpPing() {
|
|
|
874
931
|
process.exit(1);
|
|
875
932
|
}
|
|
876
933
|
|
|
934
|
+
function cmdSetErpKey() {
|
|
935
|
+
banner();
|
|
936
|
+
console.log("");
|
|
937
|
+
|
|
938
|
+
const keyFile = path.join(CLAUDE_DIR, ".erp-api-key");
|
|
939
|
+
const rawArgs = process.argv.slice(3);
|
|
940
|
+
const clear = rawArgs.includes("--clear");
|
|
941
|
+
|
|
942
|
+
if (!fs.existsSync(CLAUDE_DIR)) fs.mkdirSync(CLAUDE_DIR, { recursive: true });
|
|
943
|
+
|
|
944
|
+
if (clear) {
|
|
945
|
+
try { fs.unlinkSync(keyFile); } catch {}
|
|
946
|
+
const cfg = readConfig();
|
|
947
|
+
cfg.erp = { ...(cfg.erp || {}), enabled: false, url: (cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net", api_key_file: ".erp-api-key" };
|
|
948
|
+
writeConfig(cfg);
|
|
949
|
+
console.log(` ${GREEN}✓${RESET} ERP key removed and ERP disabled.`);
|
|
950
|
+
console.log("");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
let key = rawArgs.find((a) => a && !a.startsWith("--")) || "";
|
|
955
|
+
if (!key && !process.stdin.isTTY) {
|
|
956
|
+
try { key = fs.readFileSync(0, "utf8").trim(); } catch {}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
key = String(key || "").trim();
|
|
960
|
+
if (!key) {
|
|
961
|
+
console.log(` ${RED}✗${RESET} Missing ERP API key.`);
|
|
962
|
+
console.log(` ${DIM}Usage:${RESET} qualia-framework set-erp-key <key>`);
|
|
963
|
+
console.log(` ${DIM}Safe shell history option:${RESET} printf '%s' "$QUALIA_ERP_KEY" | qualia-framework set-erp-key`);
|
|
964
|
+
console.log("");
|
|
965
|
+
process.exit(1);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (key.length < 10) {
|
|
969
|
+
console.log(` ${YELLOW}!${RESET} Key looks short (${key.length} bytes). Saving anyway.`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
fs.writeFileSync(keyFile, key, { mode: 0o600 });
|
|
973
|
+
try { fs.chmodSync(keyFile, 0o600); } catch {}
|
|
974
|
+
|
|
975
|
+
const cfg = readConfig();
|
|
976
|
+
cfg.erp = {
|
|
977
|
+
...(cfg.erp || {}),
|
|
978
|
+
enabled: true,
|
|
979
|
+
url: (cfg.erp && cfg.erp.url) || "https://portal.qualiasolutions.net",
|
|
980
|
+
api_key_file: ".erp-api-key",
|
|
981
|
+
};
|
|
982
|
+
writeConfig(cfg);
|
|
983
|
+
|
|
984
|
+
console.log(` ${GREEN}✓${RESET} ERP key saved to ${WHITE}${keyFile}${RESET}`);
|
|
985
|
+
console.log(` ${DIM}Verify with:${RESET} ${TEAL}qualia-framework erp-ping${RESET}`);
|
|
986
|
+
console.log("");
|
|
987
|
+
}
|
|
988
|
+
|
|
877
989
|
// ─── Doctor: post-install health check ───────────────────
|
|
878
990
|
// Mirrors the spot-check that session-start.js runs once per 24h. Surfaces
|
|
879
991
|
// missing files, mis-wired hooks, stale settings.json, and version drift.
|
|
@@ -1001,6 +1113,84 @@ function cmdDoctor() {
|
|
|
1001
1113
|
process.exit(1);
|
|
1002
1114
|
}
|
|
1003
1115
|
|
|
1116
|
+
// ─── Agents: per-spawn telemetry (see docs/agent-runs.md) ─────────
|
|
1117
|
+
function cmdAgents() {
|
|
1118
|
+
banner();
|
|
1119
|
+
console.log("");
|
|
1120
|
+
|
|
1121
|
+
// Lazy require so the CLI works even if the lib is missing during dev.
|
|
1122
|
+
let lib;
|
|
1123
|
+
try { lib = require("./agent-runs.js"); }
|
|
1124
|
+
catch (e) {
|
|
1125
|
+
console.log(` ${RED}✗${RESET} agent-runs.js not available: ${e.message}`);
|
|
1126
|
+
console.log("");
|
|
1127
|
+
process.exit(1);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
const args = process.argv.slice(3);
|
|
1131
|
+
const flags = new Set(args.filter((a) => a.startsWith("--")));
|
|
1132
|
+
const sub = args.find((a) => !a.startsWith("--"));
|
|
1133
|
+
|
|
1134
|
+
const cwd = process.cwd();
|
|
1135
|
+
|
|
1136
|
+
if (sub === "prune") {
|
|
1137
|
+
const idx = args.indexOf("--before");
|
|
1138
|
+
const before = idx >= 0 ? args[idx + 1] : null;
|
|
1139
|
+
if (!before) {
|
|
1140
|
+
console.log(` ${RED}✗${RESET} usage: qualia-framework agents prune --before YYYY-MM-DD`);
|
|
1141
|
+
console.log("");
|
|
1142
|
+
process.exit(1);
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
const r = lib.prune(cwd, before);
|
|
1146
|
+
console.log(` ${GREEN}✓${RESET} Pruned ${r.removed} run record(s), ${r.logs_removed} log file(s).`);
|
|
1147
|
+
console.log("");
|
|
1148
|
+
return;
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
console.log(` ${RED}✗${RESET} ${e.message}`);
|
|
1151
|
+
console.log("");
|
|
1152
|
+
process.exit(1);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
const opts = { limit: 50 };
|
|
1157
|
+
if (flags.has("--failed")) opts.failed = true;
|
|
1158
|
+
const taskIdx = args.indexOf("--task");
|
|
1159
|
+
if (taskIdx >= 0) opts.task_id = args[taskIdx + 1];
|
|
1160
|
+
const phaseIdx = args.indexOf("--phase");
|
|
1161
|
+
if (phaseIdx >= 0) opts.phase = Number(args[phaseIdx + 1]);
|
|
1162
|
+
|
|
1163
|
+
const records = lib.read(cwd, opts);
|
|
1164
|
+
if (records.length === 0) {
|
|
1165
|
+
console.log(` ${DIM}No agent runs recorded yet${RESET} ${DIM}(or run from a project with .planning/)${RESET}`);
|
|
1166
|
+
console.log("");
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
console.log(` ${WHITE}Agent runs${RESET} ${DIM}(showing ${records.length}, newest last)${RESET}`);
|
|
1171
|
+
console.log(` ${DIM}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}`);
|
|
1172
|
+
console.log(` ${DIM}TIME AGENT PHASE TASK STATUS DURATION NOTE${RESET}`);
|
|
1173
|
+
for (const r of records) {
|
|
1174
|
+
const t = (r.finished_at || r.started_at || "").slice(11, 16);
|
|
1175
|
+
const agent = (r.agent_type || "").padEnd(11);
|
|
1176
|
+
const phase = String(r.phase ?? "-").padEnd(5);
|
|
1177
|
+
const task = (r.task_id || "-").padEnd(4);
|
|
1178
|
+
const stColor = r.status === "success" ? GREEN : r.status === "partial" ? YELLOW : RED;
|
|
1179
|
+
const status = `${stColor}${(r.status || "?").padEnd(9)}${RESET}`;
|
|
1180
|
+
const dur = r.duration_ms != null ? `${Math.round(r.duration_ms / 1000)}s`.padStart(7) : " —";
|
|
1181
|
+
const note = r.failure_reason
|
|
1182
|
+
? `${DIM}${r.failure_reason}${RESET}`
|
|
1183
|
+
: (r.commit_sha ? `${DIM}${r.commit_sha.slice(0, 7)}${RESET}` : "");
|
|
1184
|
+
console.log(` ${t} ${agent} ${phase} ${task} ${status} ${dur} ${note}`);
|
|
1185
|
+
}
|
|
1186
|
+
console.log("");
|
|
1187
|
+
const failed = records.filter((r) => r.status !== "success").length;
|
|
1188
|
+
if (failed > 0) {
|
|
1189
|
+
console.log(` ${YELLOW}${failed} non-success run(s).${RESET} ${DIM}qualia-framework agents --failed for details${RESET}`);
|
|
1190
|
+
console.log("");
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1004
1194
|
function cmdHelp() {
|
|
1005
1195
|
banner();
|
|
1006
1196
|
console.log("");
|
|
@@ -1009,13 +1199,15 @@ function cmdHelp() {
|
|
|
1009
1199
|
console.log(` qualia-framework ${TEAL}update${RESET} Update to the latest version`);
|
|
1010
1200
|
console.log(` qualia-framework ${TEAL}version${RESET} Show installed version + check for updates`);
|
|
1011
1201
|
console.log(` qualia-framework ${TEAL}uninstall${RESET} Clean removal from ~/.claude/ (${DIM}-y to skip prompts${RESET})`);
|
|
1012
|
-
console.log(` qualia-framework ${TEAL}migrate${RESET}
|
|
1202
|
+
console.log(` qualia-framework ${TEAL}migrate${RESET} Wire current hook + env layout into ~/.claude/settings.json`);
|
|
1013
1203
|
console.log(` qualia-framework ${TEAL}team${RESET} Manage team members (${DIM}list|add|remove${RESET})`);
|
|
1014
1204
|
console.log(` qualia-framework ${TEAL}traces${RESET} View recent hook telemetry`);
|
|
1015
1205
|
console.log(` qualia-framework ${TEAL}analytics${RESET} Show outcome scoring & gap cycle stats`);
|
|
1016
|
-
console.log(` qualia-framework ${TEAL}
|
|
1017
|
-
qualia-framework ${TEAL}
|
|
1018
|
-
qualia-framework ${TEAL}
|
|
1206
|
+
console.log(` qualia-framework ${TEAL}agents${RESET} Show per-agent run history (${DIM}--failed|--task ID|--phase N|prune --before${RESET})`);
|
|
1207
|
+
console.log(` qualia-framework ${TEAL}set-erp-key${RESET} Save/enable the ERP API key`);
|
|
1208
|
+
console.log(` qualia-framework ${TEAL}erp-ping${RESET} Verify ERP connectivity + API key`);
|
|
1209
|
+
console.log(` qualia-framework ${TEAL}doctor${RESET} Health-check the install (files, hooks, settings)`);
|
|
1210
|
+
console.log(` qualia-framework ${TEAL}flush${RESET} Promote daily-log → curated knowledge (memory layer)`);
|
|
1019
1211
|
console.log("");
|
|
1020
1212
|
console.log(` ${WHITE}After install:${RESET}`);
|
|
1021
1213
|
console.log(` ${TG}/qualia${RESET} What should I do next?`);
|
|
@@ -1064,10 +1256,17 @@ switch (cmd) {
|
|
|
1064
1256
|
case "migrate":
|
|
1065
1257
|
cmdMigrate();
|
|
1066
1258
|
break;
|
|
1259
|
+
case "agents":
|
|
1260
|
+
cmdAgents();
|
|
1261
|
+
break;
|
|
1067
1262
|
case "analytics":
|
|
1068
1263
|
case "stats":
|
|
1069
1264
|
cmdAnalytics();
|
|
1070
1265
|
break;
|
|
1266
|
+
case "set-erp-key":
|
|
1267
|
+
case "erp-key":
|
|
1268
|
+
cmdSetErpKey();
|
|
1269
|
+
break;
|
|
1071
1270
|
case "erp-ping":
|
|
1072
1271
|
case "ping":
|
|
1073
1272
|
cmdErpPing();
|