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 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.0.0)
118
+ ## What's Inside (v4.3.0)
119
119
 
120
- - **26 skills** — from setup to handoff, plus debug, design, review, optimize, diagnostic (`qualia-idk`), session management, skill authoring, per-phase depth (discuss, research, map), and full-journey additions (`--auto` chaining, milestone closure)
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
- - **7 hooks** (pure Node.js, cross-platform): session-start, branch-guard, pre-push tracking sync, migration-guard, pre-deploy-gate, pre-compact state save, auto-update
123
- - **5 rules**: security, frontend, design-reference, deployment, infrastructure
124
- - **19 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), help.html
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 7 hooks are real ops engineering, not theoretical:
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/ 26 slash commands
190
+ ├── skills/ 28 slash commands
187
191
  ├── agents/ 8 agent definitions (planner, builder, verifier, qa-browser, roadmapper, research-synthesizer, researcher, plan-checker)
188
- ├── hooks/ 7 Node.js hooks — cross-platform (no bash dependency)
189
- ├── bin/ state.js (state machine) + qualia-ui.js (cosmetics, banners, journey-tree) + statusline.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.0.0 release notes are the most recent section.
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
- // 5 Qualia rules.
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 all 8 hooks are wired (v2 missed block-env-edit and branch-guard)
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 === "branch-guard.js") hookDef.if = "Bash(git push*)";
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.extra && t.extra.verification);
734
- const passes = verifications.filter(t => t.extra.verification === "pass").length;
735
- const fails = verifications.filter(t => t.extra.verification === "fail").length;
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.extra && t.extra.gap_closure);
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
- const erpEnabled = !(cfg.erp && cfg.erp.enabled === false);
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} Migrate settings from v2 to v3`);
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}erp-ping${RESET} Verify ERP connectivity + API key
1017
- qualia-framework ${TEAL}doctor${RESET} Health-check the install (files, hooks, settings)
1018
- qualia-framework ${TEAL}flush${RESET} Promote daily-log curated knowledge (memory layer)`);
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();