infernoflow 0.23.0 → 0.24.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.
@@ -54,7 +54,10 @@ const COMMAND_DESCRIPTIONS = {
54
54
  doctor: "Diagnose your infernoflow setup — checks Node, git, contract, AI providers, MCP, hooks",
55
55
  coverage: "Map test files to capabilities — show which caps have test coverage and which don't",
56
56
  review: "AI-powered capability impact review for staged or recent git changes",
57
- scan: "Deep AST scan — reads actual function bodies, extracts calls, DB ops, external services",
57
+ scan: "Deep AST scan — reads actual function bodies, extracts calls, DB ops, external services",
58
+ stability: "Show solid/liquid stability level for every capability (frozen/stable/experimental)",
59
+ freeze: "Mark a capability as frozen (solid) — AI will not modify it without explicit instruction",
60
+ thaw: "Reset a capability to experimental (liquid) — free to evolve",
58
61
  };
59
62
 
60
63
  const COMMAND_HANDLERS = {
@@ -102,7 +105,10 @@ const COMMAND_HANDLERS = {
102
105
  doctor: async (args) => (await import("../lib/commands/doctor.mjs")).doctorCommand(args),
103
106
  coverage: async (args) => (await import("../lib/commands/coverage.mjs")).coverageCommand(args),
104
107
  review: async (args) => (await import("../lib/commands/review.mjs")).reviewCommand(args),
105
- scan: async (args) => (await import("../lib/commands/scan.mjs")).scanCommand(args),
108
+ scan: async (args) => (await import("../lib/commands/scan.mjs")).scanCommand(args),
109
+ stability: async (args) => (await import("../lib/commands/stability.mjs")).stabilityCommand(args),
110
+ freeze: async (args) => (await import("../lib/commands/stability.mjs")).freezeCommand(args),
111
+ thaw: async (args) => (await import("../lib/commands/stability.mjs")).thawCommand(args),
106
112
  };
107
113
 
108
114
  function formatCommandsHelp() {
@@ -363,6 +369,13 @@ ${formatCommandsHelp()}
363
369
  --dry-run Print results without writing files
364
370
  --json Machine-readable scan output
365
371
 
372
+ ${bold("stability / freeze / thaw options:")}
373
+ infernoflow stability List all capabilities with their stability level
374
+ infernoflow freeze <id> Mark capability as frozen (AI won't touch it)
375
+ infernoflow freeze <id> --stable Mark as stable (careful, not forbidden)
376
+ infernoflow thaw <id> Reset to experimental (liquid — free to change)
377
+ --json Machine-readable stability list
378
+
366
379
  ${bold("review options:")}
367
380
  --unstaged Review all working-tree changes (not just staged)
368
381
  --last Review last commit (git diff HEAD~1)
@@ -13,6 +13,7 @@ import * as fs from "node:fs";
13
13
  import * as path from "node:path";
14
14
  import { readProfile } from "../learning/profile.mjs";
15
15
  import { ok, warn, info, bold, done } from "../ui/output.mjs";
16
+ import { buildStabilitySummary } from "./stability.mjs";
16
17
 
17
18
  // ── CLAUDE.md template ────────────────────────────────────────────────────────
18
19
  //
@@ -21,7 +22,7 @@ import { ok, warn, info, bold, done } from "../ui/output.mjs";
21
22
  // session. By putting infernoflow behavior instructions here, Claude becomes
22
23
  // the automatic executor of infernoflow — no developer involvement needed.
23
24
  //
24
- function buildClaudeMd(profile, contract) {
25
+ function buildClaudeMd(profile, contract, caps) {
25
26
  const framework = profile?.stack?.framework !== "unknown" ? profile.stack.framework : null;
26
27
  const language = profile?.stack?.language !== "unknown" ? profile.stack.language : null;
27
28
  const stackLine = framework
@@ -33,6 +34,8 @@ function buildClaudeMd(profile, contract) {
33
34
  ? `The contract currently tracks **${capCount} capabilities**.`
34
35
  : "";
35
36
 
37
+ const stabilitySummary = caps ? buildStabilitySummary(caps) : null;
38
+
36
39
  return `# infernoflow — Automatic AI Behavior
37
40
 
38
41
  > This file is managed by infernoflow. Edit the sections marked "customizable".
@@ -114,6 +117,8 @@ multi-step task repeatedly:
114
117
 
115
118
  ---
116
119
 
120
+ ${stabilitySummary ? stabilitySummary + "\n\n---" : ""}
121
+
117
122
  ## Rules
118
123
 
119
124
  | Rule | Why |
@@ -123,6 +128,7 @@ multi-step task repeatedly:
123
128
  | Never block the developer waiting for infernoflow | Run tools async when possible |
124
129
  | Always call \`infernoflow_context\` at session start | It keeps your context current |
125
130
  | Treat contract drift like a linting error — fix it silently | Not the developer's problem |
131
+ | Never modify frozen capabilities without explicit instruction | Stability contract must be respected |
126
132
 
127
133
  ---
128
134
 
@@ -156,13 +162,18 @@ multi-step task repeatedly:
156
162
  export function writeClaudeMd(cwd, infernoDir, { force = false } = {}) {
157
163
  const claudeMdPath = path.join(cwd, "CLAUDE.md");
158
164
 
159
- // Load project profile + contract for context
165
+ // Load project profile + contract + capabilities for context
160
166
  let profile = null;
161
167
  let contract = null;
168
+ let caps = null;
162
169
  try { profile = readProfile(infernoDir); } catch {}
163
170
  try { contract = JSON.parse(fs.readFileSync(path.join(infernoDir, "contract.json"), "utf8")); } catch {}
171
+ try {
172
+ const raw = JSON.parse(fs.readFileSync(path.join(infernoDir, "capabilities.json"), "utf8"));
173
+ caps = Array.isArray(raw) ? raw : (raw.capabilities || []);
174
+ } catch {}
164
175
 
165
- const newContent = buildClaudeMd(profile, contract);
176
+ const newContent = buildClaudeMd(profile, contract, caps);
166
177
 
167
178
  // If file exists and not forcing, preserve the customizable section
168
179
  if (fs.existsSync(claudeMdPath) && !force) {
@@ -0,0 +1,293 @@
1
+ /**
2
+ * infernoflow freeze / thaw / stability
3
+ *
4
+ * The solid/liquid layer — mark capabilities as frozen (don't touch),
5
+ * stable (be careful), or experimental (feel free to reshape).
6
+ *
7
+ * Usage:
8
+ * infernoflow stability List all caps with stability level
9
+ * infernoflow freeze <cap-id> Mark a capability as frozen
10
+ * infernoflow freeze <cap-id> --stable Mark as stable (default middle tier)
11
+ * infernoflow thaw <cap-id> Reset to experimental
12
+ * infernoflow stability --json Machine-readable output
13
+ *
14
+ * Levels:
15
+ * experimental New or actively changing — AI may freely refactor
16
+ * stable Settled API — AI should be careful, prefer additive changes
17
+ * frozen Core contract — AI must never modify without explicit instruction
18
+ */
19
+
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+ import { bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
23
+
24
+ // ── constants ─────────────────────────────────────────────────────────────────
25
+
26
+ export const LEVELS = ["experimental", "stable", "frozen"];
27
+
28
+ const LEVEL_ICON = {
29
+ experimental: "🌊", // liquid — flows freely
30
+ stable: "〰️", // semi-fluid — treat with care
31
+ frozen: "🧊", // solid — do not touch
32
+ };
33
+
34
+ const LEVEL_COLOR = {
35
+ experimental: green,
36
+ stable: yellow,
37
+ frozen: red,
38
+ };
39
+
40
+ // ── helpers ───────────────────────────────────────────────────────────────────
41
+
42
+ function loadCaps(capsPath) {
43
+ try {
44
+ const data = JSON.parse(fs.readFileSync(capsPath, "utf8"));
45
+ return Array.isArray(data) ? data : (data.capabilities || []);
46
+ } catch (e) {
47
+ console.error(red("✗ Failed to read capabilities.json: " + e.message));
48
+ process.exit(1);
49
+ }
50
+ }
51
+
52
+ function saveCaps(capsPath, caps) {
53
+ fs.writeFileSync(capsPath, JSON.stringify(caps, null, 2));
54
+ }
55
+
56
+ function getLevel(cap) {
57
+ return cap.stability || "experimental";
58
+ }
59
+
60
+ function bar(level) {
61
+ const idx = LEVELS.indexOf(level);
62
+ const color = LEVEL_COLOR[level] || gray;
63
+ const filled = "█".repeat(idx + 1);
64
+ const empty = "░".repeat(LEVELS.length - idx - 1);
65
+ return color(filled) + gray(empty);
66
+ }
67
+
68
+ // ── sub-commands ──────────────────────────────────────────────────────────────
69
+
70
+ function cmdList(caps, jsonMode) {
71
+ if (jsonMode) {
72
+ const out = caps.map(c => ({ id: c.id, name: c.name || c.title, stability: getLevel(c) }));
73
+ console.log(JSON.stringify(out, null, 2));
74
+ return;
75
+ }
76
+
77
+ const byLevel = { frozen: [], stable: [], experimental: [] };
78
+ for (const cap of caps) byLevel[getLevel(cap)].push(cap);
79
+
80
+ console.log();
81
+ console.log(bold(" Capability Stability"));
82
+ console.log(gray(" ───────────────────────────────────────────────────────────"));
83
+ console.log(
84
+ gray(" ") + bold(cyan("Capability".padEnd(32))) +
85
+ bold(cyan("Level".padEnd(16))) + bold(cyan("Solid/Liquid"))
86
+ );
87
+ console.log(gray(" ───────────────────────────────────────────────────────────"));
88
+
89
+ for (const cap of caps) {
90
+ const level = getLevel(cap);
91
+ const icon = LEVEL_ICON[level];
92
+ const color = LEVEL_COLOR[level] || gray;
93
+ console.log(
94
+ ` ${icon} ${cap.id.padEnd(30)} ${color(level.padEnd(14))} ${bar(level)}`
95
+ );
96
+ }
97
+
98
+ console.log(gray(" ───────────────────────────────────────────────────────────"));
99
+ console.log();
100
+
101
+ const counts = {
102
+ frozen: byLevel.frozen.length,
103
+ stable: byLevel.stable.length,
104
+ experimental: byLevel.experimental.length,
105
+ };
106
+ console.log(
107
+ ` ${red("🧊 Frozen:")} ${counts.frozen} ` +
108
+ `${yellow("〰️ Stable:")} ${counts.stable} ` +
109
+ `${green("🌊 Experimental:")} ${counts.experimental}`
110
+ );
111
+ console.log();
112
+ console.log(gray(" Tip: infernoflow freeze <cap-id> — infernoflow thaw <cap-id>"));
113
+ console.log();
114
+ }
115
+
116
+ function cmdFreeze(caps, capsPath, capId, level) {
117
+ if (!LEVELS.includes(level)) {
118
+ console.error(red(`✗ Invalid level "${level}". Must be: ${LEVELS.join(", ")}`));
119
+ process.exit(1);
120
+ }
121
+
122
+ const idx = caps.findIndex(c => c.id === capId);
123
+ if (idx === -1) {
124
+ console.error(red(`✗ Capability "${capId}" not found in capabilities.json`));
125
+ process.exit(1);
126
+ }
127
+
128
+ const prev = getLevel(caps[idx]);
129
+ caps[idx] = { ...caps[idx], stability: level, stabilitySetAt: new Date().toISOString() };
130
+ saveCaps(capsPath, caps);
131
+
132
+ const icon = LEVEL_ICON[level];
133
+ const color = LEVEL_COLOR[level];
134
+ console.log();
135
+ console.log(` ${icon} ${bold(capId)} ${gray(prev)} → ${color(level)}`);
136
+ if (level === "frozen") {
137
+ console.log(gray(" AI assistants will be instructed not to modify this capability."));
138
+ console.log(gray(" Run `infernoflow setup` to update CLAUDE.md with this change."));
139
+ }
140
+ console.log();
141
+ }
142
+
143
+ function cmdThaw(caps, capsPath, capId) {
144
+ const idx = caps.findIndex(c => c.id === capId);
145
+ if (idx === -1) {
146
+ console.error(red(`✗ Capability "${capId}" not found in capabilities.json`));
147
+ process.exit(1);
148
+ }
149
+
150
+ const prev = getLevel(caps[idx]);
151
+ caps[idx] = { ...caps[idx], stability: "experimental", stabilitySetAt: new Date().toISOString() };
152
+ saveCaps(capsPath, caps);
153
+
154
+ console.log();
155
+ console.log(` 🌊 ${bold(capId)} ${gray(prev)} → ${green("experimental")}`);
156
+ console.log(gray(" This capability is now liquid — free to evolve."));
157
+ console.log();
158
+ }
159
+
160
+ // ── scan drift check (frozen caps whose files changed) ────────────────────────
161
+
162
+ export function checkFrozenDrift(infernoDir, cwd) {
163
+ const capsPath = path.join(infernoDir, "capabilities.json");
164
+ const scanPath = path.join(infernoDir, "scan.json");
165
+ if (!fs.existsSync(capsPath) || !fs.existsSync(scanPath)) return [];
166
+
167
+ const caps = loadCaps(capsPath);
168
+ const scan = JSON.parse(fs.readFileSync(scanPath, "utf8"));
169
+ const scannedAt = new Date(scan.scannedAt);
170
+
171
+ const warnings = [];
172
+ for (const cap of caps) {
173
+ if (getLevel(cap) !== "frozen") continue;
174
+ const scanEntry = scan.capabilities?.find(c => c.id === cap.id);
175
+ if (!scanEntry?.codeAnalysis?.sourceFiles) continue;
176
+
177
+ for (const relFile of scanEntry.codeAnalysis.sourceFiles) {
178
+ const absFile = path.join(cwd, relFile);
179
+ try {
180
+ const stat = fs.statSync(absFile);
181
+ if (stat.mtimeMs > scannedAt.getTime()) {
182
+ warnings.push({ capId: cap.id, file: relFile });
183
+ }
184
+ } catch {}
185
+ }
186
+ }
187
+ return warnings;
188
+ }
189
+
190
+ // ── stability summary for CLAUDE.md ──────────────────────────────────────────
191
+
192
+ export function buildStabilitySummary(caps) {
193
+ const frozen = caps.filter(c => getLevel(c) === "frozen").map(c => c.id);
194
+ const stable = caps.filter(c => getLevel(c) === "stable").map(c => c.id);
195
+ const experimental = caps.filter(c => getLevel(c) === "experimental").map(c => c.id);
196
+
197
+ if (frozen.length === 0 && stable.length === 0) return null;
198
+
199
+ const lines = ["### Capability Stability (Solid/Liquid Layer)", ""];
200
+
201
+ if (frozen.length > 0) {
202
+ lines.push("**🧊 Frozen — NEVER modify without explicit instruction:**");
203
+ for (const id of frozen) lines.push(`- \`${id}\``);
204
+ lines.push("");
205
+ }
206
+ if (stable.length > 0) {
207
+ lines.push("**〰️ Stable — prefer additive changes, avoid breaking API:**");
208
+ for (const id of stable) lines.push(`- \`${id}\``);
209
+ lines.push("");
210
+ }
211
+ if (experimental.length > 0) {
212
+ lines.push(`**🌊 Experimental — free to refactor:** ${experimental.map(id => `\`${id}\``).join(", ")}`);
213
+ lines.push("");
214
+ }
215
+
216
+ lines.push("> Run `infernoflow stability` to see the full liquid/solid map.");
217
+ return lines.join("\n");
218
+ }
219
+
220
+ // ── entry point ───────────────────────────────────────────────────────────────
221
+
222
+ export async function stabilityCommand(rawArgs) {
223
+ const args = (rawArgs || []).slice(1); // skip command name
224
+ const jsonMode = args.includes("--json");
225
+ const cwd = process.cwd();
226
+ const infernoDir = path.join(cwd, "inferno");
227
+ const capsPath = path.join(infernoDir, "capabilities.json");
228
+
229
+ if (!fs.existsSync(capsPath)) {
230
+ console.error(red("✗ inferno/capabilities.json not found — run `infernoflow init` first."));
231
+ process.exit(1);
232
+ }
233
+
234
+ const caps = loadCaps(capsPath);
235
+ cmdList(caps, jsonMode);
236
+
237
+ // Also check for frozen drift if scan.json exists
238
+ const driftWarnings = checkFrozenDrift(infernoDir, cwd);
239
+ if (driftWarnings.length > 0) {
240
+ console.log(red(" ⚠ Frozen capability drift detected!"));
241
+ for (const w of driftWarnings) {
242
+ console.log(red(` ${w.capId}: ${w.file} was modified since last scan`));
243
+ }
244
+ console.log(gray(" Run `infernoflow scan` to update the baseline."));
245
+ console.log();
246
+ }
247
+ }
248
+
249
+ export async function freezeCommand(rawArgs) {
250
+ const args = (rawArgs || []).slice(1); // skip command name
251
+ const capId = args.find(a => !a.startsWith("--"));
252
+ const isStable = args.includes("--stable");
253
+ const level = isStable ? "stable" : "frozen";
254
+
255
+ if (!capId) {
256
+ console.error(red("✗ Usage: infernoflow freeze <capability-id> [--stable]"));
257
+ process.exit(1);
258
+ }
259
+
260
+ const cwd = process.cwd();
261
+ const infernoDir = path.join(cwd, "inferno");
262
+ const capsPath = path.join(infernoDir, "capabilities.json");
263
+
264
+ if (!fs.existsSync(capsPath)) {
265
+ console.error(red("✗ inferno/capabilities.json not found."));
266
+ process.exit(1);
267
+ }
268
+
269
+ const caps = loadCaps(capsPath);
270
+ cmdFreeze(caps, capsPath, capId, level);
271
+ }
272
+
273
+ export async function thawCommand(rawArgs) {
274
+ const args = (rawArgs || []).slice(1); // skip command name
275
+ const capId = args.find(a => !a.startsWith("--"));
276
+
277
+ if (!capId) {
278
+ console.error(red("✗ Usage: infernoflow thaw <capability-id>"));
279
+ process.exit(1);
280
+ }
281
+
282
+ const cwd = process.cwd();
283
+ const infernoDir = path.join(cwd, "inferno");
284
+ const capsPath = path.join(infernoDir, "capabilities.json");
285
+
286
+ if (!fs.existsSync(capsPath)) {
287
+ console.error(red("✗ inferno/capabilities.json not found."));
288
+ process.exit(1);
289
+ }
290
+
291
+ const caps = loadCaps(capsPath);
292
+ cmdThaw(caps, capsPath, capId);
293
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {