infernoflow 0.34.1 → 0.35.4

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.
@@ -68,7 +68,7 @@ function installGitHooks(cwd, templatesRoot, force) {
68
68
 
69
69
  fs.mkdirSync(hooksDir, { recursive: true });
70
70
 
71
- const hooks = ["post-commit", "pre-push"];
71
+ const hooks = ["post-commit", "pre-push", "pre-stash"];
72
72
  const installed = [];
73
73
 
74
74
  for (const hookName of hooks) {
@@ -27,15 +27,17 @@
27
27
 
28
28
  import * as fs from "node:fs";
29
29
  import * as path from "node:path";
30
+ import * as os from "node:os";
30
31
  import { execSync } from "node:child_process";
31
32
  import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
32
33
 
33
- const INFERNO_DIR = "inferno";
34
- const HANDOFF_FILE = path.join(INFERNO_DIR, "HANDOFF.md");
35
- const SESSIONS_FILE = path.join(INFERNO_DIR, "sessions.jsonl");
36
- const STATE_FILE = path.join(INFERNO_DIR, "context-state.json");
37
- const CONTRACT_FILE = path.join(INFERNO_DIR, "contract.json");
38
- const THEME_FILE = path.join(INFERNO_DIR, "theme.json");
34
+ const INFERNO_DIR = "inferno";
35
+ const HANDOFF_FILE = path.join(INFERNO_DIR, "HANDOFF.md");
36
+ const SESSIONS_FILE = path.join(INFERNO_DIR, "sessions.jsonl");
37
+ const STATE_FILE = path.join(INFERNO_DIR, "context-state.json");
38
+ const CONTRACT_FILE = path.join(INFERNO_DIR, "contract.json");
39
+ const THEME_FILE = path.join(INFERNO_DIR, "theme.json");
40
+ const ADOPTION_FILE = path.join(INFERNO_DIR, "adoption_profile.json");
39
41
 
40
42
  function readJSON(f) { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } }
41
43
  function readFile(f) { try { return fs.readFileSync(f, "utf8"); } catch { return null; } }
@@ -45,6 +47,14 @@ function fmtDate(iso) {
45
47
  return new Date(iso).toLocaleString("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
46
48
  }
47
49
 
50
+ function fmtDuration(ms) {
51
+ if (ms < 0) return "unknown";
52
+ const h = Math.floor(ms / 3600000);
53
+ const m = Math.floor((ms % 3600000) / 60000);
54
+ if (h > 0) return `${h}h ${m}m`;
55
+ return `${m}m`;
56
+ }
57
+
48
58
  function getAllEntries() {
49
59
  if (!fs.existsSync(SESSIONS_FILE)) return [];
50
60
  return fs.readFileSync(SESSIONS_FILE, "utf8")
@@ -92,37 +102,139 @@ function copyToClipboard(text) {
92
102
  } catch { return false; }
93
103
  }
94
104
 
105
+ /** Detect current IDE from environment */
106
+ function detectIde() {
107
+ if (process.env.CURSOR_SESSION) return "Cursor";
108
+ if (process.env.COPILOT_SESSION) return "GitHub Copilot";
109
+ if (process.env.CLAUDE_CODE_SESSION) return "Claude Code";
110
+ if (process.env.WINDSURF_SESSION) return "Windsurf";
111
+ if (process.env.TERM_PROGRAM === "vscode") return "VS Code";
112
+ // Check adoption profile
113
+ const profile = readJSON(ADOPTION_FILE);
114
+ if (profile?.ide) return profile.ide;
115
+ return null;
116
+ }
117
+
118
+ /** Get git diff stat since last commit */
119
+ function getGitDiffStat() {
120
+ try {
121
+ const stat = execSync("git diff --stat HEAD 2>/dev/null || git diff --cached --stat 2>/dev/null", {
122
+ encoding: "utf8",
123
+ stdio: ["pipe", "pipe", "pipe"],
124
+ }).trim();
125
+ if (!stat) {
126
+ // Try HEAD~1 if nothing staged/unstaged
127
+ const logStat = execSync("git log --stat -1 --pretty= 2>/dev/null", {
128
+ encoding: "utf8",
129
+ stdio: ["pipe", "pipe", "pipe"],
130
+ }).trim();
131
+ return logStat || null;
132
+ }
133
+ return stat;
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /** Get recent git commits in this session */
140
+ function getGitCommits(since) {
141
+ try {
142
+ const sinceArg = since ? `--after="${since.toISOString()}"` : "-5";
143
+ const log = execSync(`git log ${sinceArg} --pretty=format:"%h %s" 2>/dev/null`, {
144
+ encoding: "utf8",
145
+ stdio: ["pipe", "pipe", "pipe"],
146
+ }).trim();
147
+ return log ? log.split("\n").filter(Boolean) : [];
148
+ } catch {
149
+ return [];
150
+ }
151
+ }
152
+
153
+ /** Detect "open threads" — attempts without resolution + entries containing TODO/WIP markers */
154
+ function findOpenThreads(sessions) {
155
+ const open = [];
156
+
157
+ // Failed/partial attempts that were never followed by a worked attempt
158
+ const attempts = sessions.filter(e => e.type === "attempt" && (e.result === "failed" || e.result === "partial" || !e.result));
159
+ for (const a of attempts) {
160
+ const followedByWorked = sessions.find(e =>
161
+ e.type === "attempt" &&
162
+ e.result === "worked" &&
163
+ new Date(e.ts) > new Date(a.ts) &&
164
+ e.summary.toLowerCase().includes(a.summary.split(" ")[0].toLowerCase())
165
+ );
166
+ if (!followedByWorked) {
167
+ open.push({ text: a.summary, ts: a.ts, kind: "unresolved-attempt" });
168
+ }
169
+ }
170
+
171
+ // Any entry explicitly marked TODO/WIP in summary
172
+ for (const e of sessions) {
173
+ if (/\b(TODO|WIP|FIXME|BLOCKED|pending)\b/i.test(e.summary)) {
174
+ if (!open.find(o => o.text === e.summary)) {
175
+ open.push({ text: e.summary, ts: e.ts, kind: "flagged" });
176
+ }
177
+ }
178
+ }
179
+
180
+ return open.slice(0, 8); // cap at 8
181
+ }
182
+
95
183
  function buildHandoff(toAgent, sinceArg, allFlag) {
96
184
  const state = readJSON(STATE_FILE) || {};
97
185
  const contract = readJSON(CONTRACT_FILE) || {};
98
186
  const theme = readJSON(THEME_FILE);
187
+ const adoption = readJSON(ADOPTION_FILE);
99
188
  const allEntries = getAllEntries();
100
189
  const sessionStart = findSessionStart(allEntries, sinceArg, allFlag);
101
- // Session entries: everything since boundary; also keep last 5 for "recent log"
102
190
  const sessions = allEntries.filter(e => new Date(e.ts || 0) > sessionStart);
103
- const recentFallback = allEntries.slice(-5); // fallback if session is empty
104
- const now = new Date().toLocaleString("en-GB", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
105
-
106
- const projectId = contract.policyId || path.basename(process.cwd());
107
- const version = contract.policyVersion || "?";
108
- const caps = (contract.capabilities || []).slice(0, 20);
109
-
110
- // ── Session memory grouped by type ────────────────────────────────────────
191
+ const recentFallback = allEntries.slice(-5);
192
+ const now = new Date();
193
+ const nowStr = now.toLocaleString("en-GB", { day: "2-digit", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
194
+
195
+ const projectId = contract.policyId || path.basename(process.cwd());
196
+ const version = contract.policyVersion || "?";
197
+ const caps = (contract.capabilities || []).slice(0, 20);
198
+ const ide = detectIde();
199
+
200
+ // Session metadata
201
+ const sessionDurationMs = sessionStart.getTime() > 0 ? now - sessionStart : -1;
202
+ const sessionDuration = fmtDuration(sessionDurationMs);
203
+ // Short session ID: hex of sessionStart ms
204
+ const sessionId = sessionStart.getTime() > 0
205
+ ? sessionStart.getTime().toString(16).slice(-6).toUpperCase()
206
+ : "ALL";
207
+
208
+ // Git data
209
+ const commits = getGitCommits(sessionStart.getTime() > 0 ? sessionStart : null);
210
+ const diffStat = getGitDiffStat();
211
+
212
+ // Memory pool
111
213
  const pool = sessions.length > 0 ? sessions : recentFallback;
112
214
  const gotchas = pool.filter(e => e.type === "gotcha");
113
215
  const decisions = pool.filter(e => e.type === "decision");
114
216
  const attempts = pool.filter(e => e.type === "attempt").filter(e => e.result === "failed" || e.result === "partial");
115
217
  const prefs = pool.filter(e => e.type === "preference");
116
- const recent = pool.slice(-8); // last 8 regardless of type
218
+ const recent = pool.slice(-8);
219
+ const openThreads = findOpenThreads(pool);
117
220
 
118
221
  const sinceStr = sessionStart.getTime() === 0
119
222
  ? "all time"
120
223
  : sessionStart.toLocaleString("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" });
121
224
 
225
+ // ── Sources header ─────────────────────────────────────────────────────────
226
+ const sourcesList = ["sessions.jsonl"];
227
+ if (state.working || state.intent) sourcesList.push("context-state.json");
228
+ if (theme) sourcesList.push("theme.json");
229
+ if (contract.capabilities?.length) sourcesList.push("contract.json");
230
+ if (adoption) sourcesList.push("adoption_profile.json");
231
+ if (commits.length) sourcesList.push("git log");
232
+
122
233
  const lines = [
123
234
  `# 🔥 infernoflow Handoff — ${projectId}`,
124
- `> Generated: ${now}${toAgent ? ` | Handing off to: **${toAgent}**` : ""}`,
125
- `> Session memory: **${sessions.length} entries** since ${sinceStr}`,
235
+ `> Generated: ${nowStr}${toAgent ? ` | Handing off to: **${toAgent}**` : ""}`,
236
+ `> Session: **#${sessionId}** · ${sessionDuration} · **${sessions.length} entries** since ${sinceStr}`,
237
+ `> Sources: ${sourcesList.join(" · ")}${ide ? ` · IDE: ${ide}` : ""}`,
126
238
  "",
127
239
  "---",
128
240
  "",
@@ -139,6 +251,16 @@ function buildHandoff(toAgent, sinceArg, allFlag) {
139
251
  lines.push("_No working state set. Run: `infernoflow context --working \"...\"` to set it._", "");
140
252
  }
141
253
 
254
+ // ── Open threads — unresolved items ───────────────────────────────────────
255
+ if (openThreads.length) {
256
+ lines.push("## 🔓 Open threads — not yet resolved", "");
257
+ for (const t of openThreads) {
258
+ const badge = t.kind === "flagged" ? "[flagged]" : "[unresolved]";
259
+ lines.push(`- ${badge} ${t.text} _(${fmtDate(t.ts)})_`);
260
+ }
261
+ lines.push("");
262
+ }
263
+
142
264
  // ── Gotchas first — most critical for a new agent ─────────────────────────
143
265
  if (gotchas.length) {
144
266
  lines.push("## ⚠ Gotchas — read these first", "");
@@ -176,6 +298,23 @@ function buildHandoff(toAgent, sinceArg, allFlag) {
176
298
  lines.push("");
177
299
  }
178
300
 
301
+ // ── Git activity this session ──────────────────────────────────────────────
302
+ if (commits.length || diffStat) {
303
+ lines.push("## Git activity this session", "");
304
+ if (commits.length) {
305
+ lines.push("**Commits:**");
306
+ for (const c of commits) lines.push(`- \`${c}\``);
307
+ lines.push("");
308
+ }
309
+ if (diffStat) {
310
+ lines.push("**Uncommitted changes:**");
311
+ lines.push("```");
312
+ lines.push(diffStat.split("\n").slice(0, 15).join("\n")); // cap at 15 lines
313
+ lines.push("```");
314
+ lines.push("");
315
+ }
316
+ }
317
+
179
318
  // ── Design system ─────────────────────────────────────────────────────────
180
319
  if (theme) {
181
320
  lines.push("## Design system", "");
@@ -206,13 +345,14 @@ function buildHandoff(toAgent, sinceArg, allFlag) {
206
345
  lines.push("## Recent session log", "");
207
346
  for (const e of recent) {
208
347
  const result = e.result ? ` [${e.result}]` : "";
209
- lines.push(`- **${e.type}**${result}: ${e.summary} _(${fmtDate(e.ts)})_`);
348
+ const src = e.source ? ` {${e.source}}` : "";
349
+ lines.push(`- **${e.type}**${result}${src}: ${e.summary} _(${fmtDate(e.ts)})_`);
210
350
  }
211
351
  lines.push("");
212
352
  }
213
353
 
214
354
  lines.push("---");
215
- lines.push("_Paste this at the start of your next AI session. Generated by infernoflow._");
355
+ lines.push(`_Session #${sessionId} · ${sessionDuration} · Generated by infernoflow._`);
216
356
 
217
357
  return lines.join("\n");
218
358
  }
@@ -252,10 +392,25 @@ export async function switchCommand(args) {
252
392
  const state = readJSON(STATE_FILE) || {};
253
393
  const contract = readJSON(CONTRACT_FILE) || {};
254
394
  const theme = readJSON(THEME_FILE);
395
+ const adoption = readJSON(ADOPTION_FILE);
255
396
  const allEntries = getAllEntries();
256
397
  const sessionStart = findSessionStart(allEntries, sinceArg, allFlag);
257
398
  const sessions = allEntries.filter(e => new Date(e.ts || 0) > sessionStart);
258
- console.log(JSON.stringify({ state, contract: { policyId: contract.policyId, policyVersion: contract.policyVersion, capabilities: contract.capabilities }, theme, sessions, sessionStart: sessionStart.toISOString(), generatedAt: new Date().toISOString() }, null, 2));
399
+ const commits = getGitCommits(sessionStart.getTime() > 0 ? sessionStart : null);
400
+ const ide = detectIde();
401
+ console.log(JSON.stringify({
402
+ state,
403
+ contract: { policyId: contract.policyId, policyVersion: contract.policyVersion, capabilities: contract.capabilities },
404
+ theme,
405
+ adoption,
406
+ sessions,
407
+ commits,
408
+ ide,
409
+ sessionStart: sessionStart.toISOString(),
410
+ sessionId: sessionStart.getTime() > 0 ? sessionStart.getTime().toString(16).slice(-6).toUpperCase() : "ALL",
411
+ sessionDuration: fmtDuration(sessionStart.getTime() > 0 ? Date.now() - sessionStart.getTime() : -1),
412
+ generatedAt: new Date().toISOString(),
413
+ }, null, 2));
259
414
  return;
260
415
  }
261
416
 
@@ -274,23 +429,36 @@ export async function switchCommand(args) {
274
429
  fs.appendFileSync(SESSIONS_FILE, JSON.stringify(entry) + "\n", "utf8");
275
430
  }
276
431
 
277
- // Print preview
278
- const allEntriesForCount = getAllEntries();
279
- const sessionStartForCount = findSessionStart(allEntriesForCount, sinceArg, allFlag);
280
- const sessionEntries = allEntriesForCount.filter(e => new Date(e.ts || 0) > sessionStartForCount);
281
- const state = readJSON(STATE_FILE) || {};
282
- const theme = readJSON(THEME_FILE);
283
- const contract = readJSON(CONTRACT_FILE) || {};
284
-
285
- console.log(" " + bold("Handoff summary"));
432
+ // ── Rich summary printout ─────────────────────────────────────────────────
433
+ const allEntriesNow = getAllEntries();
434
+ const sessionStartNow = findSessionStart(allEntriesNow, sinceArg, allFlag);
435
+ const sessionEntries = allEntriesNow.filter(e => new Date(e.ts || 0) > sessionStartNow);
436
+ const state = readJSON(STATE_FILE) || {};
437
+ const theme = readJSON(THEME_FILE);
438
+ const contract = readJSON(CONTRACT_FILE) || {};
439
+ const commits = getGitCommits(sessionStartNow.getTime() > 0 ? sessionStartNow : null);
440
+ const ide = detectIde();
441
+ const pool = sessionEntries.length > 0 ? sessionEntries : allEntriesNow.slice(-5);
442
+ const openThreads = findOpenThreads(pool);
443
+ const sessionDuration = fmtDuration(sessionStartNow.getTime() > 0 ? Date.now() - sessionStartNow.getTime() : -1);
444
+ const sessionId = sessionStartNow.getTime() > 0
445
+ ? sessionStartNow.getTime().toString(16).slice(-6).toUpperCase()
446
+ : "ALL";
447
+
448
+ // Print rich summary
449
+ console.log(" " + bold("Handoff ready"));
286
450
  console.log(" " + "─".repeat(50));
451
+ console.log(" " + gray("Session #" + sessionId + " · " + sessionDuration));
287
452
  if (state.working) console.log(" Working on " + cyan(state.working));
288
453
  if (state.intent) console.log(" Intent " + cyan(state.intent));
289
- console.log(" Session " + sessionEntries.length + " entries (total: " + allEntriesForCount.length + ")");
454
+ console.log(" Memory " + sessionEntries.length + " entries this session (total: " + allEntriesNow.length + ")");
455
+ if (openThreads.length) console.log(" Open threads " + yellow(openThreads.length + " unresolved"));
456
+ if (commits.length) console.log(" Git commits " + commits.length + " this session");
290
457
  console.log(" Capabilities " + (contract.capabilities || []).length + " registered");
291
458
  if (theme?.fonts?.primary) console.log(" Font " + theme.fonts.primary);
292
459
  if (theme?.colors?.mode) console.log(" Color mode " + theme.colors.mode);
293
- if (toAgent) console.log(" Handing off → " + cyan(toAgent));
460
+ if (ide) console.log(" IDE " + ide);
461
+ if (toAgent) console.log(" Handing off → " + cyan(toAgent));
294
462
  console.log();
295
463
 
296
464
  if (copyFlag) {
@@ -5,13 +5,16 @@
5
5
  * The inverse of `infernoflow setup`.
6
6
  *
7
7
  * What it removes:
8
- * - inferno/ — contract, capabilities, session memory, HANDOFF.md
9
- * - CLAUDE.md — auto-behavior instruction file
10
- * - .claude/settings.json with pre-approved tools
11
- * - .cursor/inferno-mcp-server.mjs — MCP server file
12
- * - .cursor/mcp.json infernoflow entry (other entries preserved)
13
- * - ~/.claude.json infernoflow mcpServers entry (other entries preserved)
14
- * - .git/hooks/post-commit / pre-push — infernoflow sections (other hooks preserved)
8
+ * - inferno/ — contract, capabilities, session memory, HANDOFF.md
9
+ * - CLAUDE.md — auto-behavior instruction file
10
+ * - .claude/settings.json pre-approved tools (infernoflow entries only)
11
+ * - .cursor/inferno-mcp-server.mjs — MCP server file
12
+ * - .cursor/hooks.json cursor hooks config (if infernoflow-only)
13
+ * - .cursor/hooks/inferno-session-draft.mjs cursor hook script
14
+ * - .cursor/mcp.json — infernoflow entry (other entries preserved)
15
+ * - inferno-mcp-server.mjs — root-level MCP server copy
16
+ * - ~/.claude.json — infernoflow mcpServers entry (other entries preserved)
17
+ * - .git/hooks/post-commit / pre-push — infernoflow sections (other hooks preserved)
15
18
  *
16
19
  * Flags:
17
20
  * --dry-run Preview what would be removed without touching anything
@@ -33,15 +36,18 @@ import * as os from "node:os";
33
36
  import * as readline from "node:readline";
34
37
  import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
35
38
 
36
- const INFERNO_DIR = "inferno";
37
- const CLAUDE_MD = "CLAUDE.md";
38
- const CLAUDE_DIR = ".claude";
39
- const CURSOR_DIR = ".cursor";
40
- const MCP_SERVER = path.join(CURSOR_DIR, "inferno-mcp-server.mjs");
41
- const CURSOR_MCP = path.join(CURSOR_DIR, "mcp.json");
42
- const CLAUDE_JSON = path.join(os.homedir(), ".claude.json");
43
- const GIT_HOOKS = [".git/hooks/post-commit", ".git/hooks/pre-push"];
44
- const INFERNO_MARKER = "# infernoflow";
39
+ const INFERNO_DIR = "inferno";
40
+ const CLAUDE_MD = "CLAUDE.md";
41
+ const CLAUDE_DIR = ".claude";
42
+ const CURSOR_DIR = ".cursor";
43
+ const MCP_SERVER = path.join(CURSOR_DIR, "inferno-mcp-server.mjs");
44
+ const MCP_SERVER_ROOT = "inferno-mcp-server.mjs"; // root-level copy from install-cursor-hooks
45
+ const CURSOR_HOOKS_JSON= path.join(CURSOR_DIR, "hooks.json");
46
+ const CURSOR_HOOK_FILE = path.join(CURSOR_DIR, "hooks", "inferno-session-draft.mjs");
47
+ const CURSOR_MCP = path.join(CURSOR_DIR, "mcp.json");
48
+ const CLAUDE_JSON = path.join(os.homedir(), ".claude.json");
49
+ const GIT_HOOKS = [".git/hooks/post-commit", ".git/hooks/pre-push"];
50
+ const INFERNO_MARKER = "# infernoflow";
45
51
 
46
52
  // ── helpers ──────────────────────────────────────────────────────────────────
47
53
 
@@ -123,9 +129,26 @@ function planClaudeDir(cwd) {
123
129
  }
124
130
 
125
131
  function planCursorMcpServer(cwd) {
126
- const p = path.join(cwd, MCP_SERVER);
127
- if (!exists(p)) return [];
128
- return [{ type: "rm", path: MCP_SERVER }];
132
+ const items = [];
133
+ // .cursor/inferno-mcp-server.mjs
134
+ if (exists(path.join(cwd, MCP_SERVER))) items.push({ type: "rm", path: MCP_SERVER });
135
+ // root-level inferno-mcp-server.mjs (written by install-cursor-hooks)
136
+ if (exists(path.join(cwd, MCP_SERVER_ROOT))) items.push({ type: "rm", path: MCP_SERVER_ROOT });
137
+ // .cursor/hooks/inferno-session-draft.mjs
138
+ if (exists(path.join(cwd, CURSOR_HOOK_FILE))) items.push({ type: "rm", path: CURSOR_HOOK_FILE });
139
+ // .cursor/hooks.json — only remove if it only contains the infernoflow hook
140
+ const hooksJsonPath = path.join(cwd, CURSOR_HOOKS_JSON);
141
+ if (exists(hooksJsonPath)) {
142
+ const cfg = readJSON(hooksJsonPath);
143
+ const hooks = cfg?.hooks || [];
144
+ const hasOnlyInferno = hooks.every(h => (h.name || h.command || "").includes("inferno"));
145
+ if (hasOnlyInferno) {
146
+ items.push({ type: "rm", path: CURSOR_HOOKS_JSON, desc: "infernoflow-only hooks config" });
147
+ } else {
148
+ items.push({ type: "edit", path: CURSOR_HOOKS_JSON, desc: "remove infernoflow hook entry (preserve others)" });
149
+ }
150
+ }
151
+ return items;
129
152
  }
130
153
 
131
154
  function planCursorMcpJson(cwd) {
@@ -212,7 +235,15 @@ function removeCursorMcpServer(cwd, plan, dryRun) {
212
235
  if (dryRun) return;
213
236
  for (const item of plan) {
214
237
  if (item.type === "rm") {
215
- try { fs.unlinkSync(path.join(cwd, MCP_SERVER)); } catch {}
238
+ try { fs.unlinkSync(path.join(cwd, item.path)); } catch {}
239
+ } else if (item.type === "edit" && item.path === CURSOR_HOOKS_JSON) {
240
+ try {
241
+ const cfg = readJSON(path.join(cwd, CURSOR_HOOKS_JSON));
242
+ if (cfg?.hooks) {
243
+ cfg.hooks = cfg.hooks.filter(h => !(h.name || h.command || "").includes("inferno"));
244
+ }
245
+ fs.writeFileSync(path.join(cwd, CURSOR_HOOKS_JSON), JSON.stringify(cfg, null, 2) + "\n", "utf8");
246
+ } catch {}
216
247
  }
217
248
  }
218
249
  }
@@ -0,0 +1,194 @@
1
+ /**
2
+ * infernoflow telemetry
3
+ *
4
+ * Opt-in, fire-and-forget usage analytics.
5
+ *
6
+ * - Stored in ~/.infernoflow/telemetry.json
7
+ * - Never enabled without explicit consent
8
+ * - Never blocks the CLI — all sends are async / best-effort
9
+ * - Never sends code, file contents, capability names, or personal data
10
+ * Only sends: command name, infernoflow version, Node version, OS platform
11
+ *
12
+ * Consent is requested lazily on the first interactive infernoflow run
13
+ * after install (if no consent decision is stored).
14
+ */
15
+
16
+ import * as fs from "node:fs";
17
+ import * as path from "node:path";
18
+ import * as os from "node:os";
19
+ import * as https from "node:https";
20
+
21
+ const CONFIG_DIR = path.join(os.homedir(), ".infernoflow");
22
+ const TELEMETRY_FILE = path.join(CONFIG_DIR, "telemetry.json");
23
+ const EVENTS_FILE = path.join(CONFIG_DIR, "events.jsonl");
24
+
25
+ const ENDPOINT = "https://telemetry.infernoflow.dev/v1/event"; // placeholder
26
+
27
+ // ── Config helpers ────────────────────────────────────────────────────────────
28
+
29
+ function readConfig() {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(TELEMETRY_FILE, "utf8"));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ function writeConfig(data) {
38
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
39
+ fs.writeFileSync(TELEMETRY_FILE, JSON.stringify(data, null, 2), "utf8");
40
+ }
41
+
42
+ /** Returns true if the user has opted in */
43
+ export function isTelemetryEnabled() {
44
+ const cfg = readConfig();
45
+ return cfg?.enabled === true;
46
+ }
47
+
48
+ /** Returns true if the user has made a consent decision (either way) */
49
+ export function hasConsentDecision() {
50
+ const cfg = readConfig();
51
+ return cfg !== null && typeof cfg.enabled === "boolean";
52
+ }
53
+
54
+ // ── Consent prompt ────────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Silently skip if consent already given, or if running non-interactively.
58
+ * Call this once at the start of each interactive CLI run.
59
+ */
60
+ export async function ensureTelemetryConsent() {
61
+ if (hasConsentDecision()) return;
62
+ if (!process.stdin.isTTY) return;
63
+
64
+ // Only ask after 3+ runs (let the user experience it first)
65
+ const cfg = readConfig() || {};
66
+ const runs = (cfg.runs || 0) + 1;
67
+ writeConfig({ ...cfg, runs, enabled: false }); // default off until explicit consent
68
+
69
+ if (runs < 3) return;
70
+
71
+ // Show one-time prompt
72
+ const { createInterface } = await import("node:readline");
73
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
74
+
75
+ const answer = await new Promise(resolve => {
76
+ process.stdout.write(
77
+ "\n 📡 Help improve infernoflow?\n" +
78
+ " Share anonymous usage data (command names only — no code, no content).\n" +
79
+ " Type 'y' to opt in, any other key to decline. You can change this later with: infernoflow telemetry on/off\n" +
80
+ " → "
81
+ );
82
+ rl.question("", resolve);
83
+ });
84
+ rl.close();
85
+
86
+ const enabled = answer.trim().toLowerCase() === "y";
87
+ writeConfig({ enabled, runs, decidedAt: new Date().toISOString() });
88
+
89
+ if (enabled) {
90
+ process.stdout.write(" ✔ Telemetry enabled — thank you! (infernoflow telemetry off to disable)\n\n");
91
+ } else {
92
+ process.stdout.write(" ✔ Got it — telemetry off. (infernoflow telemetry on to enable later)\n\n");
93
+ }
94
+ }
95
+
96
+ // ── Event tracking ────────────────────────────────────────────────────────────
97
+
98
+ /** Get current package version */
99
+ function getVersion() {
100
+ try {
101
+ const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../package.json");
102
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
103
+ } catch {
104
+ return "unknown";
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Track a command invocation. Fire-and-forget — never throws, never blocks.
110
+ * @param {string} command The command name (e.g. "log", "switch", "recap")
111
+ */
112
+ export function trackEvent(command) {
113
+ if (!isTelemetryEnabled()) return;
114
+
115
+ const event = {
116
+ ts: new Date().toISOString(),
117
+ command,
118
+ version: getVersion(),
119
+ node: process.version,
120
+ os: process.platform,
121
+ };
122
+
123
+ // Always append to local event log first
124
+ try {
125
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { recursive: true });
126
+ fs.appendFileSync(EVENTS_FILE, JSON.stringify(event) + "\n", "utf8");
127
+ } catch {}
128
+
129
+ // Fire-and-forget HTTP POST (best effort — no await)
130
+ try {
131
+ const body = JSON.stringify(event);
132
+ const url = new URL(ENDPOINT);
133
+ const req = https.request({
134
+ hostname: url.hostname,
135
+ path: url.pathname,
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
138
+ timeout: 3000,
139
+ });
140
+ req.on("error", () => {}); // silently ignore all errors
141
+ req.write(body);
142
+ req.end();
143
+ } catch {}
144
+ }
145
+
146
+ // ── CLI subcommand ────────────────────────────────────────────────────────────
147
+
148
+ export async function telemetryCommand(args) {
149
+ const { bold, cyan, gray, green, yellow, red } = await import("./ui/output.mjs");
150
+ const sub = args[0];
151
+
152
+ if (sub === "on") {
153
+ const cfg = readConfig() || {};
154
+ writeConfig({ ...cfg, enabled: true, decidedAt: new Date().toISOString() });
155
+ console.log(green("\n ✔ Telemetry enabled — thank you for helping improve infernoflow!\n"));
156
+ return;
157
+ }
158
+
159
+ if (sub === "off") {
160
+ const cfg = readConfig() || {};
161
+ writeConfig({ ...cfg, enabled: false, decidedAt: new Date().toISOString() });
162
+ console.log(green("\n ✔ Telemetry disabled.\n"));
163
+ return;
164
+ }
165
+
166
+ if (sub === "status" || !sub) {
167
+ const cfg = readConfig();
168
+ const enabled = cfg?.enabled === true;
169
+ const decided = cfg?.decidedAt ? new Date(cfg.decidedAt).toLocaleDateString() : "never";
170
+
171
+ // Count local events
172
+ let eventCount = 0;
173
+ try {
174
+ const lines = fs.readFileSync(EVENTS_FILE, "utf8").split("\n").filter(Boolean);
175
+ eventCount = lines.length;
176
+ } catch {}
177
+
178
+ console.log("\n " + bold("🔥 infernoflow telemetry status") + "\n");
179
+ console.log(" Telemetry " + (enabled ? green("enabled") : yellow("disabled")));
180
+ console.log(" Decided " + gray(decided));
181
+ console.log(" Events logged " + gray(eventCount + " (local only until enabled)"));
182
+ console.log(" Data sent " + gray("command name, infernoflow version, Node version, OS platform"));
183
+ console.log(" Data never " + gray("code, file names, capability names, email, personal data"));
184
+ console.log();
185
+ console.log(gray(" infernoflow telemetry on — enable"));
186
+ console.log(gray(" infernoflow telemetry off — disable"));
187
+ console.log();
188
+ return;
189
+ }
190
+
191
+ console.error(red(`\n ✘ Unknown subcommand: ${sub}`));
192
+ console.log(gray(" Usage: infernoflow telemetry [on | off | status]\n"));
193
+ process.exit(1);
194
+ }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env sh
2
2
  # infernoflow post-commit hook
3
3
  # Silently keeps the capability contract in sync after every commit.
4
+ # Also auto-captures decisions, gotchas, and fixes from commit messages.
4
5
  # This runs in the background — it never blocks your workflow.
5
6
  #
6
7
  # Managed by infernoflow. Re-run `infernoflow setup` to regenerate.
@@ -17,10 +18,29 @@ TOPLEVEL="$(git rev-parse --show-toplevel)"
17
18
  (
18
19
  cd "$TOPLEVEL" || exit 0
19
20
 
20
- # 1. Auto-update changelog with commit info
21
+ # 1. Auto-capture session memory from commit message
22
+ # Gets the first line of the commit message (the subject)
23
+ COMMIT_MSG="$(git log -1 --pretty=%s 2>/dev/null)"
24
+
25
+ if [ -n "$COMMIT_MSG" ]; then
26
+ # Decision patterns: chose/switched/because/use X over Y
27
+ if echo "$COMMIT_MSG" | grep -qiE "(decided|chose|switched to|use .+ over|instead of|because|prefer .+)"; then
28
+ npx infernoflow log "$COMMIT_MSG" --type decision --auto --quiet --source git-hook >/dev/null 2>&1
29
+
30
+ # Gotcha / fix patterns: bugs, workarounds, reversals
31
+ elif echo "$COMMIT_MSG" | grep -qiE "^(fix|bug|revert|hotfix|workaround|hack|broke|issue|gotcha|caveat|warn)"; then
32
+ npx infernoflow log "$COMMIT_MSG" --type gotcha --auto --quiet --source git-hook >/dev/null 2>&1
33
+
34
+ # Attempt / WIP patterns
35
+ elif echo "$COMMIT_MSG" | grep -qiE "^(wip|draft|attempt|try|experiment|test|prototype|spike)"; then
36
+ npx infernoflow log "$COMMIT_MSG" --type attempt --auto --quiet --source git-hook >/dev/null 2>&1
37
+ fi
38
+ fi
39
+
40
+ # 2. Auto-update changelog with commit info
21
41
  npx infernoflow changelog update --append >/dev/null 2>&1
22
42
 
23
- # 2. Check contract health; log issues to inferno/HOOK.log so MCP can read it
43
+ # 3. Check contract health; log issues to inferno/HOOK.log so MCP can read it
24
44
  RESULT=$(npx infernoflow check --json 2>/dev/null)
25
45
  STATUS=$(echo "$RESULT" | node -e "try{const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8'));process.stdout.write(d.status||'ok')}catch{process.stdout.write('ok')}" 2>/dev/null)
26
46