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.
@@ -74,6 +74,8 @@ const COMMAND_DESCRIPTIONS = {
74
74
  ask: "Query session memory — search gotchas, decisions, and failed attempts by keyword or type",
75
75
  recap: "End-of-session summary — what was captured, what git changes weren't logged, session health score",
76
76
  uninstall: "Remove infernoflow from a project — inferno/, CLAUDE.md, MCP server, git hooks (--dry-run to preview)",
77
+ feedback: "60-second CLI survey about how you use infernoflow (--form to open web form)",
78
+ telemetry: "Manage anonymous usage telemetry (on | off | status) — opt-in, command names only",
77
79
  };
78
80
 
79
81
  const COMMAND_HANDLERS = {
@@ -141,6 +143,8 @@ const COMMAND_HANDLERS = {
141
143
  ask: async (args) => (await import("../lib/commands/ask.mjs")).askCommand(args),
142
144
  recap: async (args) => (await import("../lib/commands/recap.mjs")).recapCommand(args),
143
145
  uninstall: async (args) => (await import("../lib/commands/uninstall.mjs")).uninstallCommand(args),
146
+ feedback: async (args) => (await import("../lib/commands/feedback.mjs")).feedbackCommand(args),
147
+ telemetry: async (args) => (await import("../lib/telemetry.mjs")).telemetryCommand(args),
144
148
  };
145
149
 
146
150
  function formatCommandsHelp() {
@@ -151,15 +155,49 @@ function formatCommandsHelp() {
151
155
  .join("\n");
152
156
  }
153
157
 
158
+ // ── Full grouped command list (infernoflow commands) ──────────────────────────
159
+ const COMMAND_GROUPS = {
160
+ "Session Memory": ["log", "ask", "switch", "recap", "stats", "theme"],
161
+ "Context": ["context", "scan", "suggest", "check", "status"],
162
+ "Code Analysis": ["graph", "impact", "why", "coverage", "stability", "freeze", "thaw", "scout"],
163
+ "Workflow": ["run", "sync", "watch", "vibe", "implement", "doc-gate", "synthesize", "agent"],
164
+ "Publishing": ["publish", "version", "changelog", "diff"],
165
+ "Team": ["team-sync", "cloud", "share", "notify", "pr-comment", "pr-impact"],
166
+ "Quality": ["health", "audit", "review", "snapshot", "export", "link"],
167
+ "Integration": ["ai", "ci", "coverage"],
168
+ "Setup": ["init", "setup", "adopt", "demo", "doctor", "onboard", "generate-skills", "upgrade", "uninstall"],
169
+ "Advanced": ["scaffold", "explain", "test", "report", "monorepo", "feedback", "telemetry"],
170
+ };
171
+
172
+ function formatCommandGroups() {
173
+ const w = 18;
174
+ return Object.entries(COMMAND_GROUPS).map(([group, cmds]) =>
175
+ ` ${bold(group + ":")}
176
+ ${cmds.join(" ")}`
177
+ ).join("\n\n");
178
+ }
179
+
154
180
  const HELP = `
155
181
  ${bold("🔥 infernoflow")} ${gray("v" + VERSION)}
156
- ${gray("The forge for liquid code — keep every AI session in sync")}
182
+ ${gray("Persistent memory for AI coding sessions")}
157
183
 
158
184
  ${bold("Usage:")}
159
- infernoflow <command> [options]
185
+ infernoflow [command] [options]
160
186
 
161
- ${bold("Commands:")}
162
- ${formatCommandsHelp()}
187
+ ${bold("Core Commands:")}
188
+ ${cyan("log")} ${gray('"..."')} Add to session memory ${gray("(--type gotcha|decision|attempt|preference)")}
189
+ ${cyan("ask")} ${gray('"..."')} Search your memory by keyword ${gray("(gotchas surface first)")}
190
+ ${cyan("switch")} Generate handoff for next AI agent
191
+ ${cyan("recap")} End-of-session health score + unlogged changes
192
+ ${cyan("status")} Contract health at a glance
193
+
194
+ ${bold("Getting Started:")}
195
+ ${cyan("setup")} One command to get fully operational
196
+ ${cyan("demo")} Interactive walkthrough ${gray("(5 minutes)")}
197
+ ${cyan("doctor")} Diagnose your setup
198
+
199
+ ${gray("Run")} ${cyan("infernoflow commands")} ${gray("to see all commands.")}
200
+ ${gray("Run")} ${cyan("infernoflow <command> --help")} ${gray("for command-specific options.")}
163
201
 
164
202
  ${bold("diff options:")}
165
203
  --ref <tag|commit> Compare against a specific ref (default: last git tag)
@@ -503,12 +541,19 @@ if (cmd === "--version" || cmd === "-v") {
503
541
  console.log(VERSION);
504
542
  process.exit(0);
505
543
  }
544
+ if (cmd === "commands") {
545
+ console.log(`\n ${bold("🔥 infernoflow")} ${gray("v" + VERSION)} ${gray("— all commands")}\n`);
546
+ console.log(formatCommandGroups());
547
+ console.log(`\n ${gray("Run")} ${cyan("infernoflow <command> --help")} ${gray("for options.")}\n`);
548
+ process.exit(0);
549
+ }
506
550
 
507
551
  const commands = Object.keys(COMMAND_HANDLERS);
508
552
 
509
553
  if (!commands.includes(cmd)) {
510
554
  console.error(red(`\nUnknown command: ${cmd}`));
511
- console.error(gray("Run: infernoflow --help\n"));
555
+ console.error(gray(`Run: infernoflow commands (see all commands)`));
556
+ console.error(gray("Run: infernoflow --help (quick start)\n"));
512
557
  process.exit(1);
513
558
  }
514
559
 
@@ -7,7 +7,12 @@
7
7
  * Sub-commands:
8
8
  * cloud init Generate a project token and write inferno/.cloud.json
9
9
  * cloud push Upload local contract to cloud
10
+ * cloud push --memory Also push session memory (sessions.jsonl) — Pro tier value prop
10
11
  * cloud pull Download latest contract from cloud
12
+ * cloud pull --memory Also pull session memory — restores inferno/sessions.jsonl
13
+ * cloud memory push Push session memory only
14
+ * cloud memory pull Pull session memory only
15
+ * cloud memory status Compare local vs remote memory entry count
11
16
  * cloud status Show local vs cloud diff
12
17
  * cloud dashboard Print hosted dashboard URL
13
18
  *
@@ -16,12 +21,14 @@
16
21
  * --endpoint <url> Override default endpoint
17
22
  * --dry-run Print what would happen without sending
18
23
  * --json Machine-readable output
24
+ * --memory Include session memory (sessions.jsonl) in push/pull
19
25
  *
20
26
  * Usage:
21
27
  * infernoflow cloud init
22
28
  * infernoflow cloud push
23
- * infernoflow cloud pull
24
- * infernoflow cloud status --json
29
+ * infernoflow cloud push --memory
30
+ * infernoflow cloud pull --memory
31
+ * infernoflow cloud memory status --json
25
32
  */
26
33
 
27
34
  import * as fs from "node:fs";
@@ -117,6 +124,233 @@ function contractHash(contract) {
117
124
  return crypto.createHash("sha256").update(JSON.stringify(contract)).digest("hex").slice(0, 12);
118
125
  }
119
126
 
127
+ // ── Session memory helpers ────────────────────────────────────────────────────
128
+
129
+ const SESSIONS_FILE = "sessions.jsonl";
130
+
131
+ function readMemory(infernoDir) {
132
+ const p = path.join(infernoDir, SESSIONS_FILE);
133
+ if (!fs.existsSync(p)) return [];
134
+ return fs.readFileSync(p, "utf8")
135
+ .split("\n").filter(Boolean)
136
+ .map(l => { try { return JSON.parse(l); } catch { return null; } })
137
+ .filter(Boolean);
138
+ }
139
+
140
+ function writeMemory(infernoDir, entries) {
141
+ const p = path.join(infernoDir, SESSIONS_FILE);
142
+ fs.writeFileSync(p, entries.map(e => JSON.stringify(e)).join("\n") + "\n", "utf8");
143
+ }
144
+
145
+ function memoryHash(entries) {
146
+ return crypto.createHash("sha256")
147
+ .update(JSON.stringify(entries.map(e => e.ts + e.summary)))
148
+ .digest("hex").slice(0, 12);
149
+ }
150
+
151
+ /**
152
+ * Merge remote memory with local — union by (ts, summary) deduplication.
153
+ * Keeps all local entries + any remote entries not already present.
154
+ */
155
+ function mergeMemory(local, remote) {
156
+ const localKeys = new Set(local.map(e => `${e.ts}|${e.summary}`));
157
+ const merged = [...local];
158
+ for (const e of remote) {
159
+ if (!localKeys.has(`${e.ts}|${e.summary}`)) {
160
+ merged.push(e);
161
+ }
162
+ }
163
+ return merged.sort((a, b) => a.ts.localeCompare(b.ts));
164
+ }
165
+
166
+ // ── Memory push/pull ──────────────────────────────────────────────────────────
167
+
168
+ async function pushMemory(args, infernoDir, config, quietly = false) {
169
+ const jsonMode = args.includes("--json");
170
+ const dryRun = args.includes("--dry-run");
171
+ const token = getToken(config, args);
172
+ const endpoint = getEndpoint(config, args);
173
+ const projectId = config?.projectId;
174
+
175
+ if (!token || !projectId) {
176
+ const msg = "No token/project found. Run: infernoflow cloud init";
177
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
178
+ else if (!quietly) warn(msg);
179
+ return { ok: false };
180
+ }
181
+
182
+ const entries = readMemory(infernoDir);
183
+ if (!entries.length) {
184
+ if (!quietly && !jsonMode) info("No session memory to push (inferno/sessions.jsonl is empty).");
185
+ return { ok: true, entries: 0 };
186
+ }
187
+
188
+ const hash = memoryHash(entries);
189
+
190
+ if (dryRun) {
191
+ if (jsonMode) console.log(JSON.stringify({ ok: true, dryRun: true, entries: entries.length, hash }));
192
+ else if (!quietly) info(`Dry run — would push ${bold(String(entries.length))} memory entries (hash: ${hash})`);
193
+ return { ok: true, dryRun: true };
194
+ }
195
+
196
+ try {
197
+ const resp = await httpsRequest(
198
+ "PUT",
199
+ `${endpoint}/api/projects/${projectId}/memory`,
200
+ { entries, hash, pushedAt: new Date().toISOString() },
201
+ token
202
+ );
203
+ const ok_flag = resp.status === 200 || resp.status === 201 || resp.status === 204;
204
+ if (jsonMode) console.log(JSON.stringify({ ok: ok_flag, entries: entries.length, hash }));
205
+ else if (!quietly) {
206
+ if (ok_flag) ok(`Pushed ${bold(String(entries.length))} memory entries`);
207
+ else warn(`Cloud returned ${resp.status}`);
208
+ }
209
+ return { ok: ok_flag, entries: entries.length };
210
+ } catch (err) {
211
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: err.message }));
212
+ else if (!quietly) warn(`Memory push failed: ${err.message}`);
213
+ return { ok: false };
214
+ }
215
+ }
216
+
217
+ async function pullMemory(args, infernoDir, config, quietly = false) {
218
+ const jsonMode = args.includes("--json");
219
+ const dryRun = args.includes("--dry-run");
220
+ const token = getToken(config, args);
221
+ const endpoint = getEndpoint(config, args);
222
+ const projectId = config?.projectId;
223
+ const forceOverwrite = args.includes("--force") || args.includes("-f");
224
+
225
+ if (!token || !projectId) {
226
+ const msg = "No token/project found. Run: infernoflow cloud init";
227
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: msg }));
228
+ else if (!quietly) warn(msg);
229
+ return { ok: false };
230
+ }
231
+
232
+ try {
233
+ const resp = await httpsRequest(
234
+ "GET",
235
+ `${endpoint}/api/projects/${projectId}/memory`,
236
+ null,
237
+ token
238
+ );
239
+
240
+ if (resp.status !== 200) {
241
+ const errMsg = `Cloud returned ${resp.status}`;
242
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: errMsg }));
243
+ else if (!quietly) warn(errMsg);
244
+ return { ok: false };
245
+ }
246
+
247
+ const remote = resp.body?.entries;
248
+ if (!remote || !remote.length) {
249
+ if (!quietly && !jsonMode) info("No session memory in cloud yet. Push first.");
250
+ return { ok: true, entries: 0 };
251
+ }
252
+
253
+ const local = readMemory(infernoDir);
254
+ const merged = forceOverwrite ? remote : mergeMemory(local, remote);
255
+ const newCount = merged.length - local.length;
256
+
257
+ if (dryRun) {
258
+ if (jsonMode) console.log(JSON.stringify({ ok: true, dryRun: true, remote: remote.length, local: local.length, merged: merged.length }));
259
+ else if (!quietly) info(`Dry run — would merge ${bold(String(remote.length))} remote + ${bold(String(local.length))} local = ${bold(String(merged.length))} entries`);
260
+ return { ok: true, dryRun: true };
261
+ }
262
+
263
+ writeMemory(infernoDir, merged);
264
+
265
+ if (jsonMode) console.log(JSON.stringify({ ok: true, remote: remote.length, local: local.length, merged: merged.length, newEntries: newCount }));
266
+ else if (!quietly) ok(`Merged ${bold(String(remote.length))} remote entries → ${bold(String(merged.length))} total (${newCount} new)`);
267
+ return { ok: true, entries: merged.length };
268
+ } catch (err) {
269
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: err.message }));
270
+ else if (!quietly) warn(`Memory pull failed: ${err.message}`);
271
+ return { ok: false };
272
+ }
273
+ }
274
+
275
+ async function subcmdMemory(args, cwd, infernoDir) {
276
+ const jsonMode = args.includes("--json");
277
+ const config = readCloudConfig(infernoDir);
278
+ const token = getToken(config, args);
279
+ const endpoint = getEndpoint(config, args);
280
+
281
+ const sub2 = args[0];
282
+ const sub2Args = args.slice(1);
283
+
284
+ if (sub2 === "push") {
285
+ if (!jsonMode) header("Pushing session memory to cloud");
286
+ return pushMemory(sub2Args, infernoDir, config);
287
+ }
288
+
289
+ if (sub2 === "pull") {
290
+ if (!jsonMode) header("Pulling session memory from cloud");
291
+ return pullMemory(sub2Args, infernoDir, config);
292
+ }
293
+
294
+ if (sub2 === "status" || !sub2) {
295
+ const local = readMemory(infernoDir);
296
+ const projectId = config?.projectId;
297
+
298
+ if (!config || !token) {
299
+ if (jsonMode) console.log(JSON.stringify({ ok: false, error: "Not initialised. Run: infernoflow cloud init" }));
300
+ else warn("Cloud not configured. Run: infernoflow cloud init");
301
+ return;
302
+ }
303
+
304
+ let remoteCount = null;
305
+ let remoteHash = null;
306
+ let reachable = false;
307
+
308
+ try {
309
+ const resp = await httpsRequest("GET", `${endpoint}/api/projects/${projectId}/memory`, null, token);
310
+ if (resp.status === 200 && resp.body?.entries) {
311
+ reachable = true;
312
+ remoteCount = resp.body.entries.length;
313
+ remoteHash = memoryHash(resp.body.entries);
314
+ }
315
+ } catch {}
316
+
317
+ const localHash = local.length ? memoryHash(local) : null;
318
+
319
+ if (jsonMode) {
320
+ console.log(JSON.stringify({
321
+ ok: true,
322
+ local: { entries: local.length, hash: localHash },
323
+ remote: reachable ? { entries: remoteCount, hash: remoteHash } : null,
324
+ reachable,
325
+ inSync: localHash === remoteHash,
326
+ }));
327
+ return;
328
+ }
329
+
330
+ console.log();
331
+ console.log(` ${bold("infernoflow cloud memory status")}`);
332
+ console.log();
333
+ console.log(` Local: ${bold(String(local.length))} entries ${gray("(hash: " + (localHash || "none") + ")")}`);
334
+ if (!reachable) {
335
+ console.log(` Cloud: ${yellow("unreachable")}`);
336
+ } else {
337
+ console.log(` Cloud: ${bold(String(remoteCount))} entries ${gray("(hash: " + (remoteHash || "none") + ")")}`);
338
+ if (localHash === remoteHash) console.log(`\n ${green("✔")} Memory in sync`);
339
+ else console.log(`\n ${yellow("⚠")} Out of sync — run ${cyan("infernoflow cloud memory push")} or ${cyan("infernoflow cloud memory pull")}`);
340
+ }
341
+ console.log();
342
+ return;
343
+ }
344
+
345
+ console.log();
346
+ console.log(` ${bold("infernoflow cloud memory")} — session memory sync`);
347
+ console.log();
348
+ console.log(` ${cyan("infernoflow cloud memory push")} Upload sessions.jsonl to cloud`);
349
+ console.log(` ${cyan("infernoflow cloud memory pull")} Download + merge remote memory`);
350
+ console.log(` ${cyan("infernoflow cloud memory status")} Compare local vs remote`);
351
+ console.log();
352
+ }
353
+
120
354
  // ── Sub-commands ──────────────────────────────────────────────────────────────
121
355
 
122
356
  async function subcmdInit(args, cwd, infernoDir) {
@@ -243,6 +477,13 @@ async function subcmdPush(args, cwd, infernoDir) {
243
477
  console.log(` ${gray("Dashboard:")} ${cyan(`${endpoint}/p/${projectId}`)}`);
244
478
  console.log();
245
479
  }
480
+
481
+ // Also push session memory if --memory flag set
482
+ if (args.includes("--memory")) {
483
+ if (!jsonMode) info("Pushing session memory...");
484
+ await pushMemory(args, infernoDir, config, jsonMode);
485
+ if (!jsonMode) ok("Session memory pushed");
486
+ }
246
487
  } else {
247
488
  const errMsg = `Cloud returned ${resp.status}`;
248
489
  if (jsonMode) { console.log(JSON.stringify({ ok: false, error: errMsg, status: resp.status })); }
@@ -351,6 +592,12 @@ async function subcmdPull(args, cwd, infernoDir) {
351
592
  if (onlyLocal.length) warn(`${onlyLocal.length} local-only capabilities were overwritten.`);
352
593
  console.log();
353
594
  }
595
+
596
+ // Also pull session memory if --memory flag set
597
+ if (args.includes("--memory")) {
598
+ if (!jsonMode) info("Pulling session memory...");
599
+ await pullMemory(args, infernoDir, config, jsonMode);
600
+ }
354
601
  } catch (err) {
355
602
  if (jsonMode) { console.log(JSON.stringify({ ok: false, error: err.message })); }
356
603
  else { warn(`Cloud unreachable: ${err.message}`); }
@@ -501,19 +748,24 @@ export async function cloudCommand(rawArgs) {
501
748
  return subcmdStatus(subArgs, cwd, infernoDir);
502
749
  case "dashboard":
503
750
  return subcmdDashboard(subArgs, cwd, infernoDir);
751
+ case "memory":
752
+ return subcmdMemory(subArgs, cwd, infernoDir);
504
753
  default: {
505
754
  const jsonMode = args.includes("--json");
506
- const msg = `Unknown cloud sub-command: ${subcmd || "(none)"}. Use: init | push | pull | status | dashboard`;
755
+ const msg = `Unknown cloud sub-command: ${subcmd || "(none)"}. Use: init | push | pull | memory | status | dashboard`;
507
756
  if (jsonMode) { console.log(JSON.stringify({ ok: false, error: msg })); }
508
757
  else {
509
758
  console.log();
510
- console.log(` ${bold("infernoflow cloud")} — hosted contract sync`);
759
+ console.log(` ${bold("infernoflow cloud")} — hosted contract + memory sync`);
511
760
  console.log();
512
- console.log(` ${cyan("infernoflow cloud init")} Set up cloud sync for this project`);
513
- console.log(` ${cyan("infernoflow cloud push")} Upload local contract to cloud`);
514
- console.log(` ${cyan("infernoflow cloud pull")} Download latest contract from cloud`);
515
- console.log(` ${cyan("infernoflow cloud status")} Compare local vs cloud`);
516
- console.log(` ${cyan("infernoflow cloud dashboard")} Open hosted dashboard in browser`);
761
+ console.log(` ${cyan("infernoflow cloud init")} Set up cloud sync for this project`);
762
+ console.log(` ${cyan("infernoflow cloud push")} Upload local contract to cloud`);
763
+ console.log(` ${cyan("infernoflow cloud push --memory")} Also push sessions.jsonl`);
764
+ console.log(` ${cyan("infernoflow cloud pull")} Download latest contract from cloud`);
765
+ console.log(` ${cyan("infernoflow cloud pull --memory")} Also pull + merge session memory`);
766
+ console.log(` ${cyan("infernoflow cloud memory push/pull")} Session memory only`);
767
+ console.log(` ${cyan("infernoflow cloud status")} Compare local vs cloud`);
768
+ console.log(` ${cyan("infernoflow cloud dashboard")} Open hosted dashboard in browser`);
517
769
  console.log();
518
770
  }
519
771
  }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * infernoflow feedback
3
+ *
4
+ * Collects in-CLI feedback about infernoflow and optionally opens the web form.
5
+ *
6
+ * Usage:
7
+ * infernoflow feedback Interactive 5-question survey
8
+ * infernoflow feedback --form Open Google Form in browser
9
+ * infernoflow feedback --json Print last stored feedback as JSON
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import * as os from "node:os";
15
+ import * as readline from "node:readline";
16
+ import { execSync } from "node:child_process";
17
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
18
+
19
+ const FEEDBACK_FORM_URL = "https://forms.gle/infernoflow-feedback"; // placeholder — replace with real form
20
+ const FEEDBACK_FILE = path.join(os.homedir(), ".infernoflow", "feedback.json");
21
+
22
+ const QUESTIONS = [
23
+ {
24
+ id: "usage",
25
+ label: "How often do you use infernoflow?",
26
+ choices: ["daily", "a few times a week", "rarely", "just started"],
27
+ },
28
+ {
29
+ id: "ide",
30
+ label: "Which IDE are you using?",
31
+ choices: ["VS Code + Copilot", "Cursor", "Claude Code", "Windsurf", "Other"],
32
+ },
33
+ {
34
+ id: "top_command",
35
+ label: "Which infernoflow command do you use most?",
36
+ choices: ["log", "switch", "recap", "status / check", "context", "other"],
37
+ },
38
+ {
39
+ id: "missing",
40
+ label: "What feature do you wish infernoflow had?",
41
+ freeText: true,
42
+ },
43
+ {
44
+ id: "email",
45
+ label: "Email (optional — for follow-up questions):",
46
+ freeText: true,
47
+ optional: true,
48
+ },
49
+ ];
50
+
51
+ function saveFeedback(responses) {
52
+ const dir = path.dirname(FEEDBACK_FILE);
53
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
54
+
55
+ const record = {
56
+ ts: new Date().toISOString(),
57
+ version: getVersion(),
58
+ responses,
59
+ };
60
+
61
+ // Append to array
62
+ let existing = [];
63
+ if (fs.existsSync(FEEDBACK_FILE)) {
64
+ try { existing = JSON.parse(fs.readFileSync(FEEDBACK_FILE, "utf8")); } catch {}
65
+ }
66
+ existing.push(record);
67
+ fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(existing, null, 2), "utf8");
68
+ return record;
69
+ }
70
+
71
+ function getVersion() {
72
+ try {
73
+ const pkgPath = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../../package.json");
74
+ return JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
75
+ } catch {
76
+ return "unknown";
77
+ }
78
+ }
79
+
80
+ function openBrowser(url) {
81
+ const platform = process.platform;
82
+ try {
83
+ if (platform === "darwin") execSync(`open "${url}"`, { stdio: "ignore" });
84
+ else if (platform === "win32") execSync(`start "" "${url}"`, { stdio: "ignore" });
85
+ else execSync(`xdg-open "${url}"`, { stdio: "ignore" });
86
+ return true;
87
+ } catch {
88
+ return false;
89
+ }
90
+ }
91
+
92
+ async function prompt(rl, question) {
93
+ return new Promise(resolve => rl.question(question, resolve));
94
+ }
95
+
96
+ async function runSurvey() {
97
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
98
+
99
+ console.log("\n " + bold("🔥 infernoflow feedback") + "\n");
100
+ console.log(gray(" Takes ~60 seconds. Helps make infernoflow better.\n"));
101
+
102
+ const responses = {};
103
+
104
+ for (const q of QUESTIONS) {
105
+ console.log(cyan(` ${q.label}`));
106
+
107
+ if (q.choices) {
108
+ q.choices.forEach((c, i) => console.log(gray(` ${i + 1}. ${c}`)));
109
+ const raw = await prompt(rl, " → ");
110
+ const idx = parseInt(raw.trim()) - 1;
111
+ responses[q.id] = (idx >= 0 && idx < q.choices.length) ? q.choices[idx] : raw.trim();
112
+ } else {
113
+ const raw = await prompt(rl, " → ");
114
+ responses[q.id] = raw.trim() || (q.optional ? null : "—");
115
+ }
116
+ console.log();
117
+ }
118
+
119
+ rl.close();
120
+
121
+ const record = saveFeedback(responses);
122
+
123
+ console.log(green(" ✔ Feedback saved — thank you!\n"));
124
+ console.log(gray(" Stored in: ~/.infernoflow/feedback.json"));
125
+ console.log(gray(` Version: ${record.version}`));
126
+
127
+ // Nudge to share
128
+ console.log(gray("\n To share more detail or attach files, run: infernoflow feedback --form\n"));
129
+ }
130
+
131
+ export async function feedbackCommand(args) {
132
+ const has = (f) => args.includes(f);
133
+
134
+ // ── --form mode ─────────────────────────────────────────────────────────────
135
+ if (has("--form")) {
136
+ console.log(cyan(`\n Opening feedback form → ${FEEDBACK_FORM_URL}\n`));
137
+ const opened = openBrowser(FEEDBACK_FORM_URL);
138
+ if (!opened) {
139
+ console.log(yellow(" Could not open browser automatically."));
140
+ console.log(gray(` Please open manually: ${FEEDBACK_FORM_URL}\n`));
141
+ }
142
+ return;
143
+ }
144
+
145
+ // ── --json mode ──────────────────────────────────────────────────────────────
146
+ if (has("--json")) {
147
+ if (!fs.existsSync(FEEDBACK_FILE)) {
148
+ console.log(JSON.stringify([], null, 2));
149
+ return;
150
+ }
151
+ try {
152
+ const data = JSON.parse(fs.readFileSync(FEEDBACK_FILE, "utf8"));
153
+ console.log(JSON.stringify(data, null, 2));
154
+ } catch {
155
+ console.log(JSON.stringify([], null, 2));
156
+ }
157
+ return;
158
+ }
159
+
160
+ // ── Interactive survey ───────────────────────────────────────────────────────
161
+ if (!process.stdin.isTTY) {
162
+ console.log(red(" ✘ infernoflow feedback requires an interactive terminal.\n"));
163
+ console.log(gray(" Run in a terminal or use: infernoflow feedback --form\n"));
164
+ process.exit(1);
165
+ }
166
+
167
+ await runSurvey();
168
+ }
@@ -15,6 +15,11 @@
15
15
  * infernoflow log --show 5 Print last 5 entries
16
16
  * infernoflow log --clear Archive and clear the log
17
17
  * infernoflow log --json Print entries as JSON array
18
+ *
19
+ * Auto-capture flags (for git hooks / automation):
20
+ * infernoflow log "..." --auto Mark as auto-captured; silent exit if no inferno/
21
+ * infernoflow log "..." --quiet Suppress all output
22
+ * infernoflow log "..." --source git-hook Tag the origin of this log entry
18
23
  */
19
24
 
20
25
  import * as fs from "node:fs";
@@ -37,12 +42,14 @@ function readEntries() {
37
42
  .filter(Boolean);
38
43
  }
39
44
 
40
- function appendEntry(entry) {
45
+ function appendEntry(entry, { auto = false, quiet = false } = {}) {
41
46
  if (!fs.existsSync(INFERNO_DIR)) {
42
- console.error(red(" ✘ inferno/ not foundrun: infernoflow init\n"));
47
+ if (auto) return false; // silently skip hook running in non-inferno project
48
+ if (!quiet) console.error(red(" ✘ inferno/ not found — run: infernoflow init\n"));
43
49
  process.exit(1);
44
50
  }
45
51
  fs.appendFileSync(SESSIONS_FILE, JSON.stringify(entry) + "\n", "utf8");
52
+ return true;
46
53
  }
47
54
 
48
55
  function detectAgent() {
@@ -78,6 +85,9 @@ export async function logCommand(args) {
78
85
  const showFlag = has("--show");
79
86
  const clearFlag = has("--clear");
80
87
  const jsonFlag = has("--json");
88
+ const autoFlag = has("--auto"); // auto-captured; silent exit if no inferno/
89
+ const quietFlag = has("--quiet"); // suppress all console output
90
+ const source = flag("--source", null); // origin tag, e.g. "git-hook"
81
91
 
82
92
  // ── Show mode ───────────────────────────────────────────────────────────────
83
93
  if (showFlag || jsonFlag) {
@@ -116,8 +126,11 @@ export async function logCommand(args) {
116
126
  }
117
127
 
118
128
  // ── Append mode ─────────────────────────────────────────────────────────────
119
- // Collect the message — everything that's not a flag
120
- const messageTokens = args.filter(a => !a.startsWith("--") && a !== flag("--type","") && a !== flag("--result","") && a !== flag("--agent",""));
129
+ // Collect the message — everything that's not a flag or a flag value
130
+ const flagValues = new Set([
131
+ flag("--type",""), flag("--result",""), flag("--agent",""), flag("--source","")
132
+ ].filter(Boolean));
133
+ const messageTokens = args.filter(a => !a.startsWith("--") && !flagValues.has(a));
121
134
  const summary = messageTokens.join(" ").trim();
122
135
 
123
136
  if (!summary) {
@@ -131,7 +144,8 @@ export async function logCommand(args) {
131
144
  console.log(gray(' infernoflow log --json Print as JSON'));
132
145
  console.log();
133
146
  console.log(gray(" Types: note · attempt · decision · gotcha · preference · theme · handoff · error"));
134
- console.log(gray(" Results: worked · failed · partial · unknown\n"));
147
+ console.log(gray(" Results: worked · failed · partial · unknown"));
148
+ console.log(gray(" Auto-capture: --auto (silent skip if no inferno/) · --quiet · --source <name>\n"));
135
149
  return;
136
150
  }
137
151
 
@@ -140,11 +154,11 @@ export async function logCommand(args) {
140
154
  const agent = flag("--agent", detectAgent());
141
155
 
142
156
  if (!VALID_TYPES.includes(type)) {
143
- console.error(red(` ✘ Invalid type: ${type}. Valid: ${VALID_TYPES.join(", ")}\n`));
157
+ if (!quietFlag) console.error(red(` ✘ Invalid type: ${type}. Valid: ${VALID_TYPES.join(", ")}\n`));
144
158
  process.exit(1);
145
159
  }
146
160
  if (result && !VALID_RESULTS.includes(result)) {
147
- console.error(red(` ✘ Invalid result: ${result}. Valid: ${VALID_RESULTS.join(", ")}\n`));
161
+ if (!quietFlag) console.error(red(` ✘ Invalid result: ${result}. Valid: ${VALID_RESULTS.join(", ")}\n`));
148
162
  process.exit(1);
149
163
  }
150
164
 
@@ -153,12 +167,18 @@ export async function logCommand(args) {
153
167
  agent,
154
168
  type,
155
169
  summary,
156
- ...(result ? { result } : {}),
170
+ ...(result ? { result } : {}),
171
+ ...(source ? { source } : {}),
172
+ ...(autoFlag ? { auto: true } : {}),
157
173
  };
158
174
 
159
- appendEntry(entry);
175
+ const written = appendEntry(entry, { auto: autoFlag, quiet: quietFlag });
176
+ if (!written) return; // auto mode, no inferno/ — skip silently
160
177
 
161
- const typeLabel = type !== "note" ? cyan(` [${type}]`) : "";
162
- const resultLabel = result ? gray(` ${result}`) : "";
163
- console.log(green(` Logged${typeLabel}${resultLabel}: `) + summary + "\n");
178
+ if (!quietFlag) {
179
+ const typeLabel = type !== "note" ? cyan(` [${type}]`) : "";
180
+ const resultLabel = result ? gray(` ${result}`) : "";
181
+ const sourceLabel = source ? gray(` (via ${source})`) : "";
182
+ console.log(green(` ✔ Logged${typeLabel}${resultLabel}${sourceLabel}: `) + summary + "\n");
183
+ }
164
184
  }