infernoflow 0.20.0 → 0.21.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/dist/bin/infernoflow.mjs
CHANGED
|
@@ -49,6 +49,8 @@ const COMMAND_DESCRIPTIONS = {
|
|
|
49
49
|
export: "Export contract to OpenAPI, Backstage catalog-info.yaml, CSV, or Markdown",
|
|
50
50
|
snapshot: "Save/diff/restore named snapshots of the capability contract",
|
|
51
51
|
health: "Compute a 0–100 health score across coverage, docs, freshness, completeness, drift",
|
|
52
|
+
vibe: "Vibe coding mode — watches files, auto-syncs contract, regenerates context on every save",
|
|
53
|
+
adopt: "Interactive wizard to adopt infernoflow in an existing project (detect → review → wire up)",
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
const COMMAND_HANDLERS = {
|
|
@@ -91,6 +93,8 @@ const COMMAND_HANDLERS = {
|
|
|
91
93
|
export: async (args) => (await import("../lib/commands/export.mjs")).exportCommand(args),
|
|
92
94
|
snapshot: async (args) => (await import("../lib/commands/snapshot.mjs")).snapshotCommand(args),
|
|
93
95
|
health: async (args) => (await import("../lib/commands/health.mjs")).healthCommand(args),
|
|
96
|
+
vibe: async (args) => (await import("../lib/commands/vibe.mjs")).vibeCommand(args),
|
|
97
|
+
adopt: async (args) => (await import("../lib/commands/adoptWizard.mjs")).adoptWizardCommand(args),
|
|
94
98
|
};
|
|
95
99
|
|
|
96
100
|
function formatCommandsHelp() {
|
|
@@ -287,6 +291,26 @@ ${formatCommandsHelp()}
|
|
|
287
291
|
--fail-on high|medium Exit 1 if unreviewed caps at given severity exist
|
|
288
292
|
--json Machine-readable output
|
|
289
293
|
|
|
294
|
+
${bold("vibe options:")}
|
|
295
|
+
--dir <dirs> Comma-separated directories to watch (default: auto-detected)
|
|
296
|
+
--no-suggest Disable automatic contract sync on file save
|
|
297
|
+
--no-context Disable CONTEXT.md regeneration
|
|
298
|
+
--interval <secs> Debounce seconds between saves (default: 4)
|
|
299
|
+
--port <n> Also run a mini status dashboard on localhost:<n>
|
|
300
|
+
--silent Suppress all terminal output (pure background mode)
|
|
301
|
+
|
|
302
|
+
${bold("adopt options:")}
|
|
303
|
+
--dir <dirs> Source directories to scan (default: src,lib,app,api,routes,controllers)
|
|
304
|
+
--yes, -y Auto-approve all candidates (non-interactive)
|
|
305
|
+
--json Machine-readable output, implies --yes
|
|
306
|
+
|
|
307
|
+
${bold("init --template options:")}
|
|
308
|
+
--template rest-api REST API (Express/Fastify/Hono) starter
|
|
309
|
+
--template nextjs Next.js fullstack app starter
|
|
310
|
+
--template cli CLI tool (Node.js/Python) starter
|
|
311
|
+
--template graphql GraphQL API (Apollo/Pothos) starter
|
|
312
|
+
--template monorepo Monorepo workspace starter
|
|
313
|
+
|
|
290
314
|
${bold("scout options:")}
|
|
291
315
|
--dir <dirs> Comma-separated directories to scan (default: src,lib,app,api,routes)
|
|
292
316
|
--apply Write discovered capabilities to the contract file
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow adopt
|
|
3
|
+
*
|
|
4
|
+
* Interactive adoption wizard for existing projects.
|
|
5
|
+
* Takes a project from zero to fully wired in one guided flow.
|
|
6
|
+
*
|
|
7
|
+
* Steps:
|
|
8
|
+
* 1. Detect project type, framework, language
|
|
9
|
+
* 2. Scout capabilities in source files
|
|
10
|
+
* 3. Interactive approve / rename / skip each candidate
|
|
11
|
+
* 4. Write capabilities.json + contract.json
|
|
12
|
+
* 5. Generate starter scenarios
|
|
13
|
+
* 6. Install git hooks
|
|
14
|
+
* 7. Regenerate CONTEXT.md
|
|
15
|
+
* 8. Show health score and next steps
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* infernoflow adopt Full interactive wizard
|
|
19
|
+
* infernoflow adopt --yes Auto-approve all (non-interactive)
|
|
20
|
+
* infernoflow adopt --dir src Custom source directory
|
|
21
|
+
* infernoflow adopt --json Machine-readable, implies --yes
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import * as readline from "node:readline";
|
|
27
|
+
import { spawnSync } from "node:child_process";
|
|
28
|
+
import { done, warn, info, bold, cyan, gray, green, yellow, red } from "../ui/output.mjs";
|
|
29
|
+
|
|
30
|
+
// ── Detection ─────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function detectProject(cwd) {
|
|
33
|
+
const has = (...files) => files.some(f => fs.existsSync(path.join(cwd, f)));
|
|
34
|
+
const read = (f) => { try { return JSON.parse(fs.readFileSync(path.join(cwd, f), "utf8")); } catch { return {}; } };
|
|
35
|
+
|
|
36
|
+
const pkg = has("package.json") ? read("package.json") : {};
|
|
37
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
38
|
+
|
|
39
|
+
let framework = "unknown", lang = "javascript", type = "backend";
|
|
40
|
+
|
|
41
|
+
if (has("tsconfig.json")) lang = "typescript";
|
|
42
|
+
if (has("Pipfile","pyproject.toml","requirements.txt")) lang = "python";
|
|
43
|
+
if (has("Gemfile")) lang = "ruby";
|
|
44
|
+
if (has("go.mod")) lang = "go";
|
|
45
|
+
if (has("Cargo.toml")) lang = "rust";
|
|
46
|
+
|
|
47
|
+
if (deps["next"]) { framework = "nextjs"; type = "fullstack"; }
|
|
48
|
+
else if (deps["nuxt"]) { framework = "nuxt"; type = "fullstack"; }
|
|
49
|
+
else if (deps["react"]) { framework = "react"; type = "frontend"; }
|
|
50
|
+
else if (deps["vue"]) { framework = "vue"; type = "frontend"; }
|
|
51
|
+
else if (deps["@angular/core"]) { framework = "angular"; type = "frontend"; }
|
|
52
|
+
else if (deps["express"]) { framework = "express"; type = "backend"; }
|
|
53
|
+
else if (deps["fastify"]) { framework = "fastify"; type = "backend"; }
|
|
54
|
+
else if (deps["hono"]) { framework = "hono"; type = "backend"; }
|
|
55
|
+
else if (deps["@nestjs/core"]) { framework = "nestjs"; type = "backend"; }
|
|
56
|
+
else if (deps["graphql"]) { framework = "graphql"; type = "backend"; }
|
|
57
|
+
else if (has("config/routes.rb")) { framework = "rails"; type = "fullstack"; }
|
|
58
|
+
else if (has("manage.py")) { framework = "django"; type = "backend"; }
|
|
59
|
+
|
|
60
|
+
if (pkg.bin || has("bin/")) type = "cli";
|
|
61
|
+
if (has("nx.json","turbo.json","pnpm-workspace.yaml")) type = "monorepo";
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
name: pkg.name || path.basename(cwd),
|
|
65
|
+
version: pkg.version || "0.1.0",
|
|
66
|
+
description: pkg.description || "",
|
|
67
|
+
lang, framework, type,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Scout ─────────────────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const SRC_EXTS = new Set([".js",".mjs",".cjs",".ts",".tsx",".jsx",".py",".rb"]);
|
|
74
|
+
const SKIP_DIRS = new Set(["node_modules",".git","dist","build",".next","__pycache__","vendor","coverage","inferno"]);
|
|
75
|
+
|
|
76
|
+
const PATTERNS = [
|
|
77
|
+
{
|
|
78
|
+
regex: /(?:app|router|server)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"`]([^'"`]+)['"`]/gi,
|
|
79
|
+
extract: m => ({ id: routeId(m[2], m[1]), hint: `${m[1].toUpperCase()} ${m[2]}`, confidence: 0.85 }),
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
filePattern: /[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/,
|
|
83
|
+
extract: fp => {
|
|
84
|
+
const m = fp.match(/[/\\](pages[/\\]api|app[/\\]api)[/\\](.+)\.(js|ts|mjs)$/);
|
|
85
|
+
if (!m) return null;
|
|
86
|
+
const r = m[2].replace(/[/\\]index$/, "").replace(/\[([^\]]+)\]/g, ":$1");
|
|
87
|
+
return { id: routeId("/" + r, "api"), hint: `Next.js API: /${r}`, confidence: 0.9 };
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
regex: /export\s+(?:async\s+)?(?:function|class|const)\s+([A-Z][A-Za-z0-9]+)/g,
|
|
92
|
+
extract: m => ({ id: camelId(m[1]), hint: `export ${m[1]}`, confidence: 0.65 }),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
function routeId(r, method) {
|
|
97
|
+
const id = r.replace(/^\//, "").replace(/\/:?[^/]+/g, "").replace(/\//g, "-")
|
|
98
|
+
.replace(/[^a-zA-Z0-9-]/g, "").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase();
|
|
99
|
+
return id || method.toLowerCase() + "-root";
|
|
100
|
+
}
|
|
101
|
+
function camelId(name) {
|
|
102
|
+
return name.replace(/([A-Z])/g, "-$1").toLowerCase().replace(/^-/, "").replace(/-+/g, "-");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function* walk(dir) {
|
|
106
|
+
if (!fs.existsSync(dir)) return;
|
|
107
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
108
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
109
|
+
const full = path.join(dir, e.name);
|
|
110
|
+
if (e.isDirectory()) yield* walk(full);
|
|
111
|
+
else if (e.isFile() && SRC_EXTS.has(path.extname(e.name))) yield full;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function scout(dirs, cwd) {
|
|
116
|
+
const seen = new Map();
|
|
117
|
+
const add = (id, r, file) => {
|
|
118
|
+
if (!seen.has(id) || r.confidence > seen.get(id).confidence)
|
|
119
|
+
seen.set(id, { ...r, source: path.relative(cwd, file) });
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
for (const dir of dirs) {
|
|
123
|
+
for (const file of walk(dir)) {
|
|
124
|
+
for (const pat of PATTERNS) {
|
|
125
|
+
if (pat.filePattern && pat.filePattern.test(file)) {
|
|
126
|
+
const r = pat.extract(file);
|
|
127
|
+
if (r && r.id.length >= 3) add(r.id, r, file);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
let content;
|
|
131
|
+
try { content = fs.readFileSync(file, "utf8"); } catch { continue; }
|
|
132
|
+
for (const pat of PATTERNS) {
|
|
133
|
+
if (!pat.regex) continue;
|
|
134
|
+
pat.regex.lastIndex = 0;
|
|
135
|
+
let m;
|
|
136
|
+
while ((m = pat.regex.exec(content)) !== null) {
|
|
137
|
+
const r = pat.extract(m);
|
|
138
|
+
if (r && r.id.length >= 3 && !["index","app","main","root","default","handler"].includes(r.id))
|
|
139
|
+
add(r.id, r, file);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return [...seen.values()].filter(c => c.confidence >= 0.5).sort((a, b) => b.confidence - a.confidence);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Interactive review ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
function ask(rl, q) { return new Promise(r => rl.question(q, r)); }
|
|
150
|
+
|
|
151
|
+
async function review(candidates, autoYes) {
|
|
152
|
+
if (autoYes) return candidates.filter(c => c.confidence >= 0.6);
|
|
153
|
+
|
|
154
|
+
const approved = [];
|
|
155
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
156
|
+
|
|
157
|
+
console.log();
|
|
158
|
+
console.log(` ${bold("Review candidates")} ${cyan("y")} approve · ${yellow("e")} edit · ${red("n")} skip · ${gray("a")} approve all · ${gray("q")} stop`);
|
|
159
|
+
console.log();
|
|
160
|
+
|
|
161
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
162
|
+
const c = candidates[i];
|
|
163
|
+
const conf = `${Math.round(c.confidence * 100)}%`;
|
|
164
|
+
const col = c.confidence >= 0.8 ? green : c.confidence >= 0.65 ? yellow : gray;
|
|
165
|
+
process.stdout.write(
|
|
166
|
+
` [${i + 1}/${candidates.length}] ${bold(c.id.padEnd(30))} ${col(conf)} ${gray(c.hint)}\n` +
|
|
167
|
+
` ${gray(c.source)}\n > `
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const ans = (await ask(rl, "")).trim().toLowerCase();
|
|
171
|
+
if (ans === "q") break;
|
|
172
|
+
if (ans === "a") {
|
|
173
|
+
for (let j = i; j < candidates.length; j++) approved.push(candidates[j]);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
if (ans === "n") { console.log(` ${gray("skipped")}\n`); continue; }
|
|
177
|
+
|
|
178
|
+
let id = c.id;
|
|
179
|
+
if (ans === "e") {
|
|
180
|
+
const newId = (await ask(rl, ` New id (${c.id}): `)).trim();
|
|
181
|
+
if (newId && /^[a-z0-9-_]+$/.test(newId)) id = newId;
|
|
182
|
+
}
|
|
183
|
+
approved.push({ ...c, id });
|
|
184
|
+
console.log(` ${green("✔")} ${bold(id)}\n`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
rl.close();
|
|
188
|
+
return approved;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Writers ───────────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
function writeFiles(infernoDir, approved, project) {
|
|
194
|
+
// contract.json
|
|
195
|
+
const cp = path.join(infernoDir, "contract.json");
|
|
196
|
+
const existing = fs.existsSync(cp) ? JSON.parse(fs.readFileSync(cp, "utf8")) : {};
|
|
197
|
+
fs.writeFileSync(cp, JSON.stringify({
|
|
198
|
+
...existing,
|
|
199
|
+
policyId: existing.policyId || project.name.replace(/[^a-zA-Z0-9-]/g, "-"),
|
|
200
|
+
policyVersion: existing.policyVersion || 1,
|
|
201
|
+
capabilities: approved.map(c => c.id),
|
|
202
|
+
}, null, 2) + "\n");
|
|
203
|
+
|
|
204
|
+
// capabilities.json
|
|
205
|
+
fs.writeFileSync(path.join(infernoDir, "capabilities.json"), JSON.stringify({
|
|
206
|
+
capabilities: approved.map(c => ({
|
|
207
|
+
id: c.id, description: c.hint,
|
|
208
|
+
since: new Date().toISOString().slice(0, 10), source: "adopt",
|
|
209
|
+
})),
|
|
210
|
+
}, null, 2) + "\n");
|
|
211
|
+
|
|
212
|
+
// Starter scenarios
|
|
213
|
+
const scenDir = path.join(infernoDir, "scenarios");
|
|
214
|
+
if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
|
|
215
|
+
for (const c of approved) {
|
|
216
|
+
const sp = path.join(scenDir, `${c.id}.json`);
|
|
217
|
+
if (fs.existsSync(sp)) continue;
|
|
218
|
+
fs.writeFileSync(sp, JSON.stringify({
|
|
219
|
+
id: `${c.id}-happy-path`, capability: c.id,
|
|
220
|
+
description: `Happy path for ${c.description || c.id}`,
|
|
221
|
+
steps: [{ action: "invoke", target: c.id, input: {} }, { action: "assert", field: "status", value: "success" }],
|
|
222
|
+
capabilitiesCovered: [c.id],
|
|
223
|
+
}, null, 2) + "\n");
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function runCli(args, cwd) {
|
|
228
|
+
return spawnSync("infernoflow", args, {
|
|
229
|
+
cwd, encoding: "utf8", timeout: 30_000,
|
|
230
|
+
stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_COLOR: "1" },
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
export async function adoptWizardCommand(rawArgs) {
|
|
237
|
+
const args = rawArgs.slice(1);
|
|
238
|
+
const autoYes = args.includes("--yes") || args.includes("-y") || args.includes("--json");
|
|
239
|
+
const jsonMode = args.includes("--json");
|
|
240
|
+
const cwd = process.cwd();
|
|
241
|
+
|
|
242
|
+
const dirIdx = args.indexOf("--dir");
|
|
243
|
+
const dirs = dirIdx !== -1
|
|
244
|
+
? args[dirIdx + 1].split(",").map(d => path.resolve(cwd, d.trim()))
|
|
245
|
+
: ["src","lib","app","api","pages","routes","controllers","services","handlers"]
|
|
246
|
+
.map(d => path.join(cwd, d)).filter(d => fs.existsSync(d));
|
|
247
|
+
if (!dirs.length) dirs.push(cwd);
|
|
248
|
+
|
|
249
|
+
const project = detectProject(cwd);
|
|
250
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
251
|
+
if (!fs.existsSync(infernoDir)) {
|
|
252
|
+
fs.mkdirSync(infernoDir, { recursive: true });
|
|
253
|
+
fs.mkdirSync(path.join(infernoDir, "scenarios"), { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!jsonMode) {
|
|
257
|
+
console.log();
|
|
258
|
+
console.log(` ${bold("🔥 infernoflow adopt")} ${gray("— existing project wizard")}`);
|
|
259
|
+
console.log();
|
|
260
|
+
console.log(` ${bold("Project:")} ${cyan(project.name)} v${project.version}`);
|
|
261
|
+
console.log(` ${bold("Language:")} ${project.lang} ${bold("Framework:")} ${project.framework} ${bold("Type:")} ${project.type}`);
|
|
262
|
+
console.log();
|
|
263
|
+
info(`Scanning ${dirs.map(d => path.relative(cwd, d) || ".").join(", ")} …`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const candidates = scout(dirs, cwd);
|
|
267
|
+
|
|
268
|
+
if (!candidates.length) {
|
|
269
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No capabilities detected." })); return; }
|
|
270
|
+
warn("No capabilities detected. Use --dir src,lib,api");
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (!jsonMode) {
|
|
275
|
+
console.log();
|
|
276
|
+
console.log(` ${bold(`${candidates.length} capability candidates detected`)}`);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const approved = await review(candidates, autoYes);
|
|
280
|
+
|
|
281
|
+
if (!approved.length) {
|
|
282
|
+
if (jsonMode) { console.log(JSON.stringify({ ok: false, error: "No capabilities approved." })); return; }
|
|
283
|
+
warn("Nothing approved — exiting.");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
writeFiles(infernoDir, approved, project);
|
|
288
|
+
runCli(["context"], cwd);
|
|
289
|
+
|
|
290
|
+
// Offer hooks in interactive mode
|
|
291
|
+
let hooksInstalled = false;
|
|
292
|
+
if (!autoYes) {
|
|
293
|
+
const rl3 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
294
|
+
const ans = (await ask(rl3, `\n Install git hooks for auto-sync? ${gray("[Y/n]")} `)).trim().toLowerCase();
|
|
295
|
+
rl3.close();
|
|
296
|
+
if (ans !== "n") { runCli(["setup", "--yes"], cwd); hooksInstalled = true; }
|
|
297
|
+
} else {
|
|
298
|
+
runCli(["setup", "--yes"], cwd); hooksInstalled = true;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (jsonMode) {
|
|
302
|
+
console.log(JSON.stringify({ ok: true, project, detected: candidates.length, approved: approved.length, capabilities: approved.map(c => c.id), hooksInstalled }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
console.log();
|
|
307
|
+
done(`${bold(String(approved.length))} capabilities adopted`);
|
|
308
|
+
console.log();
|
|
309
|
+
console.log(` ${green("✔")} ${bold("inferno/contract.json")} written`);
|
|
310
|
+
console.log(` ${green("✔")} ${bold("inferno/capabilities.json")} written`);
|
|
311
|
+
console.log(` ${green("✔")} ${bold("inferno/scenarios/")} scaffolded`);
|
|
312
|
+
console.log(` ${green("✔")} ${bold("inferno/CONTEXT.md")} generated`);
|
|
313
|
+
if (hooksInstalled) console.log(` ${green("✔")} Git hooks installed`);
|
|
314
|
+
console.log();
|
|
315
|
+
console.log(` ${bold("Next steps:")}`);
|
|
316
|
+
console.log(` ${cyan("1.")} Paste ${bold("inferno/CONTEXT.md")} into Claude or Cursor`);
|
|
317
|
+
console.log(` ${cyan("2.")} Run ${bold("infernoflow vibe")} to keep everything in sync automatically`);
|
|
318
|
+
console.log(` ${cyan("3.")} Run ${bold("infernoflow health")} to see your starting score`);
|
|
319
|
+
console.log();
|
|
320
|
+
}
|
|
@@ -155,6 +155,76 @@ export async function initCommand(args) {
|
|
|
155
155
|
const force = args.includes("--force") || args.includes("-f");
|
|
156
156
|
const yes = args.includes("--yes") || args.includes("-y");
|
|
157
157
|
const adopt = args.includes("--adopt");
|
|
158
|
+
|
|
159
|
+
// ── Template shortcut ──────────────────────────────────────────────────────
|
|
160
|
+
const templateIdx = args.indexOf("--template");
|
|
161
|
+
const templateName = templateIdx !== -1 ? args[templateIdx + 1] : null;
|
|
162
|
+
|
|
163
|
+
if (templateName) {
|
|
164
|
+
let tmplMod;
|
|
165
|
+
try { tmplMod = await import("../templates/index.mjs"); } catch {}
|
|
166
|
+
const tmpl = tmplMod?.getTemplate(templateName);
|
|
167
|
+
if (!tmpl) {
|
|
168
|
+
const available = tmplMod ? tmplMod.listTemplates().map(t => t.name).join(", ") : "rest-api, nextjs, cli, graphql, monorepo";
|
|
169
|
+
warn(`Unknown template: ${templateName}. Available: ${available}`);
|
|
170
|
+
process.exit(1);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
174
|
+
const scenDir = path.join(infernoDir, "scenarios");
|
|
175
|
+
if (!fs.existsSync(infernoDir)) fs.mkdirSync(infernoDir, { recursive: true });
|
|
176
|
+
if (!fs.existsSync(scenDir)) fs.mkdirSync(scenDir, { recursive: true });
|
|
177
|
+
|
|
178
|
+
const policyId = detectProjectName(cwd);
|
|
179
|
+
const caps = tmpl.capabilities;
|
|
180
|
+
|
|
181
|
+
// Write contract.json
|
|
182
|
+
fs.writeFileSync(path.join(infernoDir, "contract.json"), JSON.stringify({
|
|
183
|
+
policyId, policyVersion: 1,
|
|
184
|
+
capabilities: caps.map(c => c.id),
|
|
185
|
+
}, null, 2) + "\n");
|
|
186
|
+
|
|
187
|
+
// Write capabilities.json
|
|
188
|
+
fs.writeFileSync(path.join(infernoDir, "capabilities.json"), JSON.stringify({
|
|
189
|
+
capabilities: caps.map(c => ({
|
|
190
|
+
id: c.id, description: c.description,
|
|
191
|
+
since: new Date().toISOString().slice(0, 10), source: `template:${templateName}`,
|
|
192
|
+
})),
|
|
193
|
+
}, null, 2) + "\n");
|
|
194
|
+
|
|
195
|
+
// Write one scenario per capability
|
|
196
|
+
for (const cap of caps) {
|
|
197
|
+
fs.writeFileSync(path.join(scenDir, `${cap.id}.json`), JSON.stringify({
|
|
198
|
+
id: `${cap.id}-happy-path`, capability: cap.id,
|
|
199
|
+
description: `Happy path for ${cap.description || cap.id}`,
|
|
200
|
+
steps: [
|
|
201
|
+
{ action: "invoke", target: cap.id, input: {} },
|
|
202
|
+
{ action: "assert", field: "status", value: "success" },
|
|
203
|
+
],
|
|
204
|
+
capabilitiesCovered: [cap.id],
|
|
205
|
+
}, null, 2) + "\n");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Write CHANGELOG.md
|
|
209
|
+
writeChangelog(path.join(infernoDir, "CHANGELOG.md"), policyId);
|
|
210
|
+
|
|
211
|
+
// Write CONTEXT.md hint
|
|
212
|
+
fs.writeFileSync(path.join(infernoDir, "CONTEXT.md"),
|
|
213
|
+
`# ${policyId} — infernoflow context\n\n> Template: ${templateName} — ${tmpl.description}\n\n## Hint\n${tmpl.contextHint}\n\n## Capabilities (${caps.length})\n${caps.map(c => `- \`${c.id}\`: ${c.description}`).join("\n")}\n`
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
if (tmpl.scripts) {
|
|
217
|
+
info(`Suggested package.json scripts for this template:`);
|
|
218
|
+
Object.entries(tmpl.scripts).forEach(([k, v]) => console.log(` ${bold(k)}: ${gray(v)}`));
|
|
219
|
+
console.log();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
done(`Initialised from template ${bold(cyan(templateName))} — ${bold(String(caps.length))} capabilities`);
|
|
223
|
+
console.log();
|
|
224
|
+
info(`Run ${cyan("infernoflow vibe")} to start vibe coding mode`);
|
|
225
|
+
console.log();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
158
228
|
const cursorHooks = args.includes("--cursor-hooks");
|
|
159
229
|
const vscodeCopilotHooks = args.includes("--vscode-copilot-hooks");
|
|
160
230
|
const reportJson = args.includes("--report-json");
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow vibe
|
|
3
|
+
*
|
|
4
|
+
* Persistent background process for vibe coding sessions.
|
|
5
|
+
* Completely invisible to the developer — just leave it running in a terminal.
|
|
6
|
+
*
|
|
7
|
+
* What it does every cycle:
|
|
8
|
+
* 1. Watches source files for saves
|
|
9
|
+
* 2. On save: runs infernoflow suggest to sync the contract
|
|
10
|
+
* 3. Regenerates CONTEXT.md so the next AI prompt is always fresh
|
|
11
|
+
* 4. Watches inferno/ for contract changes → updates CONTEXT.md automatically
|
|
12
|
+
* 5. Prints a one-line status ticker — nothing noisy
|
|
13
|
+
*
|
|
14
|
+
* Also provides a session summary on exit (Ctrl+C):
|
|
15
|
+
* - Files changed
|
|
16
|
+
* - Capabilities added / updated
|
|
17
|
+
* - Health score delta
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* infernoflow vibe Start vibe mode (default dirs)
|
|
21
|
+
* infernoflow vibe --dir src,api Watch specific directories
|
|
22
|
+
* infernoflow vibe --no-suggest Watch + context only, skip suggest
|
|
23
|
+
* infernoflow vibe --no-context Skip CONTEXT.md regeneration
|
|
24
|
+
* infernoflow vibe --interval 5 Debounce seconds (default 4)
|
|
25
|
+
* infernoflow vibe --silent Suppress all output (pure background)
|
|
26
|
+
* infernoflow vibe --port 7337 Also start dashboard on this port
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as path from "node:path";
|
|
31
|
+
import * as http from "node:http";
|
|
32
|
+
import * as os from "node:os";
|
|
33
|
+
import { execSync, spawnSync } from "node:child_process";
|
|
34
|
+
import { bold, cyan, gray, green, yellow, red, info, warn } from "../ui/output.mjs";
|
|
35
|
+
|
|
36
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
const SOURCE_EXTENSIONS = new Set([".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".rb", ".go", ".rs"]);
|
|
39
|
+
const IGNORE_DIRS = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", "vendor", "coverage", "inferno"]);
|
|
40
|
+
|
|
41
|
+
function runCli(args, cwd, silent = false) {
|
|
42
|
+
const result = spawnSync("infernoflow", args, {
|
|
43
|
+
cwd,
|
|
44
|
+
encoding: "utf8",
|
|
45
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
46
|
+
timeout: 30_000,
|
|
47
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
48
|
+
});
|
|
49
|
+
if (!silent && result.error) return { ok: false, out: "", err: result.error.message };
|
|
50
|
+
return { ok: result.status === 0, out: result.stdout || "", err: result.stderr || "" };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function timestamp() {
|
|
54
|
+
return new Date().toLocaleTimeString("en-US", { hour12: false });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readContractCapCount(infernoDir) {
|
|
58
|
+
for (const f of ["contract.json", "capabilities.json"]) {
|
|
59
|
+
const p = path.join(infernoDir, f);
|
|
60
|
+
if (!fs.existsSync(p)) continue;
|
|
61
|
+
try {
|
|
62
|
+
const data = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
63
|
+
return (data.capabilities || []).length;
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readHealthScore(cwd) {
|
|
70
|
+
const result = spawnSync("infernoflow", ["health", "--json"], {
|
|
71
|
+
cwd, encoding: "utf8", timeout: 20_000, stdio: ["ignore", "pipe", "pipe"],
|
|
72
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
73
|
+
});
|
|
74
|
+
try { return JSON.parse(result.stdout || "{}").score ?? null; } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Session state ─────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
class VibeSession {
|
|
80
|
+
constructor() {
|
|
81
|
+
this.startedAt = new Date();
|
|
82
|
+
this.filesChanged = new Set();
|
|
83
|
+
this.suggestRuns = 0;
|
|
84
|
+
this.contextUpdates = 0;
|
|
85
|
+
this.capsAtStart = 0;
|
|
86
|
+
this.capsNow = 0;
|
|
87
|
+
this.scoreAtStart = null;
|
|
88
|
+
this.scoreNow = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
recordChange(filePath) { this.filesChanged.add(filePath); }
|
|
92
|
+
recordSuggest() { this.suggestRuns++; }
|
|
93
|
+
recordContext() { this.contextUpdates++; }
|
|
94
|
+
|
|
95
|
+
elapsed() {
|
|
96
|
+
const ms = Date.now() - this.startedAt.getTime();
|
|
97
|
+
const mins = Math.floor(ms / 60000);
|
|
98
|
+
const secs = Math.floor((ms % 60000) / 1000);
|
|
99
|
+
return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
printSummary() {
|
|
103
|
+
const capDelta = this.capsNow - this.capsAtStart;
|
|
104
|
+
const scoreDelta = this.scoreNow !== null && this.scoreAtStart !== null
|
|
105
|
+
? this.scoreNow - this.scoreAtStart : null;
|
|
106
|
+
|
|
107
|
+
console.log();
|
|
108
|
+
console.log(` ${bold("🔥 infernoflow vibe — session summary")}`);
|
|
109
|
+
console.log();
|
|
110
|
+
console.log(` ${bold("Duration:")} ${this.elapsed()}`);
|
|
111
|
+
console.log(` ${bold("Files watched:")} ${this.filesChanged.size}`);
|
|
112
|
+
console.log(` ${bold("Syncs run:")} ${this.suggestRuns}`);
|
|
113
|
+
console.log(` ${bold("Context refreshes:")} ${this.contextUpdates}`);
|
|
114
|
+
|
|
115
|
+
if (capDelta !== 0) {
|
|
116
|
+
const col = capDelta > 0 ? green : yellow;
|
|
117
|
+
console.log(` ${bold("Capabilities:")} ${col((capDelta > 0 ? "+" : "") + capDelta)} (${this.capsAtStart} → ${this.capsNow})`);
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` ${bold("Capabilities:")} ${this.capsNow} (no change)`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (scoreDelta !== null) {
|
|
123
|
+
const col = scoreDelta > 0 ? green : scoreDelta < 0 ? yellow : gray;
|
|
124
|
+
console.log(` ${bold("Health score:")} ${col((scoreDelta > 0 ? "+" : "") + scoreDelta)} (${this.scoreAtStart} → ${this.scoreNow})`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Status ticker ─────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
const SPINNERS = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
134
|
+
let spinIdx = 0;
|
|
135
|
+
|
|
136
|
+
function tick(msg, col = gray) {
|
|
137
|
+
const spinner = SPINNERS[spinIdx++ % SPINNERS.length];
|
|
138
|
+
process.stdout.write(`\r ${col(spinner)} ${msg} `);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function tickDone(msg) {
|
|
142
|
+
process.stdout.write(`\r ${green("✔")} ${msg} \n`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function tickWarn(msg) {
|
|
146
|
+
process.stdout.write(`\r ${yellow("⚠")} ${msg} \n`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Core cycle ────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
async function runCycle(changedFile, cwd, infernoDir, opts, session) {
|
|
152
|
+
const rel = path.relative(cwd, changedFile);
|
|
153
|
+
session.recordChange(changedFile);
|
|
154
|
+
|
|
155
|
+
if (!opts.silent) tick(`${cyan(rel)} changed — syncing…`, cyan);
|
|
156
|
+
|
|
157
|
+
// 1. Suggest (sync contract)
|
|
158
|
+
if (!opts.noSuggest) {
|
|
159
|
+
const desc = `updated ${path.basename(changedFile)}`;
|
|
160
|
+
const r = runCli(["suggest", desc, "--json"], cwd, true);
|
|
161
|
+
session.recordSuggest();
|
|
162
|
+
|
|
163
|
+
if (!opts.silent) {
|
|
164
|
+
if (r.ok) {
|
|
165
|
+
tickDone(`Synced: ${cyan(rel)}`);
|
|
166
|
+
} else {
|
|
167
|
+
tickWarn(`Suggest skipped (${gray("no AI provider")})`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 2. Regenerate CONTEXT.md
|
|
173
|
+
if (!opts.noContext) {
|
|
174
|
+
const r = runCli(["context"], cwd, true);
|
|
175
|
+
session.recordContext();
|
|
176
|
+
if (!opts.silent && r.ok) {
|
|
177
|
+
tick(`Context refreshed`, gray);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 3. Update cap count
|
|
182
|
+
session.capsNow = readContractCapCount(infernoDir);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── File watcher ──────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
function watchDirs(dirs, cwd, infernoDir, opts, session) {
|
|
188
|
+
const watchers = [];
|
|
189
|
+
const debounceMap = new Map();
|
|
190
|
+
|
|
191
|
+
const onFileChange = (eventType, filePath) => {
|
|
192
|
+
if (!SOURCE_EXTENSIONS.has(path.extname(filePath))) return;
|
|
193
|
+
if ([...IGNORE_DIRS].some(d => filePath.includes(`${path.sep}${d}${path.sep}`))) return;
|
|
194
|
+
|
|
195
|
+
// Debounce per file
|
|
196
|
+
if (debounceMap.has(filePath)) clearTimeout(debounceMap.get(filePath));
|
|
197
|
+
debounceMap.set(filePath, setTimeout(async () => {
|
|
198
|
+
debounceMap.delete(filePath);
|
|
199
|
+
await runCycle(filePath, cwd, infernoDir, opts, session);
|
|
200
|
+
}, opts.interval * 1000));
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
for (const dir of dirs) {
|
|
204
|
+
if (!fs.existsSync(dir)) continue;
|
|
205
|
+
try {
|
|
206
|
+
const w = fs.watch(dir, { recursive: true }, (eventType, filename) => {
|
|
207
|
+
if (!filename) return;
|
|
208
|
+
onFileChange(eventType, path.join(dir, filename));
|
|
209
|
+
});
|
|
210
|
+
watchers.push(w);
|
|
211
|
+
} catch {}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Also watch inferno/ for contract changes → regenerate CONTEXT.md
|
|
215
|
+
try {
|
|
216
|
+
const iw = fs.watch(infernoDir, { recursive: true }, (eventType, filename) => {
|
|
217
|
+
if (!filename) return;
|
|
218
|
+
if (!filename.endsWith(".json") && !filename.endsWith(".md")) return;
|
|
219
|
+
if (filename.includes("CONTEXT")) return; // avoid loop
|
|
220
|
+
|
|
221
|
+
if (debounceMap.has("__inferno__")) clearTimeout(debounceMap.get("__inferno__"));
|
|
222
|
+
debounceMap.set("__inferno__", setTimeout(async () => {
|
|
223
|
+
debounceMap.delete("__inferno__");
|
|
224
|
+
if (!opts.noContext) {
|
|
225
|
+
runCli(["context"], cwd, true);
|
|
226
|
+
session.recordContext();
|
|
227
|
+
if (!opts.silent) tick(`Contract updated — context refreshed`, gray);
|
|
228
|
+
}
|
|
229
|
+
session.capsNow = readContractCapCount(infernoDir);
|
|
230
|
+
}, 1000));
|
|
231
|
+
});
|
|
232
|
+
watchers.push(iw);
|
|
233
|
+
} catch {}
|
|
234
|
+
|
|
235
|
+
return watchers;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Dashboard mini-server ─────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function startMiniDashboard(port, cwd, infernoDir) {
|
|
241
|
+
const server = http.createServer((req, res) => {
|
|
242
|
+
if (req.url === "/status") {
|
|
243
|
+
const caps = readContractCapCount(infernoDir);
|
|
244
|
+
const health = readHealthScore(cwd);
|
|
245
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
246
|
+
res.end(JSON.stringify({ ok: true, capabilities: caps, health, ts: new Date().toISOString() }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
250
|
+
res.end(`<!DOCTYPE html><html><head><meta charset="UTF-8">
|
|
251
|
+
<meta http-equiv="refresh" content="10">
|
|
252
|
+
<title>🔥 vibe</title>
|
|
253
|
+
<style>body{font-family:system-ui;background:#0f1117;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;flex-direction:column;gap:16px}
|
|
254
|
+
h1{color:#f97316;margin:0}.sub{color:#64748b;font-size:14px}</style></head>
|
|
255
|
+
<body><h1>🔥 infernoflow vibe</h1><div class="sub">Auto-refreshes every 10s</div>
|
|
256
|
+
<script>
|
|
257
|
+
fetch('/status').then(r=>r.json()).then(d=>{
|
|
258
|
+
document.body.innerHTML='<h1>🔥 vibe</h1><p style="color:#22c55e">'+d.capabilities+' capabilities</p>'
|
|
259
|
+
+(d.health!==null?'<p>Health: '+d.health+'/100</p>':'')
|
|
260
|
+
+'<p style="color:#64748b;font-size:12px">'+new Date(d.ts).toLocaleTimeString()+'</p>';
|
|
261
|
+
});
|
|
262
|
+
</script></body></html>`);
|
|
263
|
+
});
|
|
264
|
+
server.listen(port, "127.0.0.1", () => {});
|
|
265
|
+
return server;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ── Entry ─────────────────────────────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
export async function vibeCommand(rawArgs) {
|
|
271
|
+
const args = rawArgs.slice(1);
|
|
272
|
+
const silent = args.includes("--silent");
|
|
273
|
+
const noSuggest = args.includes("--no-suggest");
|
|
274
|
+
const noContext = args.includes("--no-context");
|
|
275
|
+
const cwd = process.cwd();
|
|
276
|
+
const infernoDir = path.join(cwd, "inferno");
|
|
277
|
+
|
|
278
|
+
if (!fs.existsSync(infernoDir)) {
|
|
279
|
+
warn("inferno/ not found. Run: infernoflow init");
|
|
280
|
+
process.exit(1);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Parse flags
|
|
284
|
+
const dirIdx = args.indexOf("--dir");
|
|
285
|
+
const intervalIdx = args.indexOf("--interval");
|
|
286
|
+
const portIdx = args.indexOf("--port");
|
|
287
|
+
|
|
288
|
+
const interval = intervalIdx !== -1 ? parseInt(args[intervalIdx + 1], 10) : 4;
|
|
289
|
+
const port = portIdx !== -1 ? parseInt(args[portIdx + 1], 10) : null;
|
|
290
|
+
|
|
291
|
+
const scanDirs = dirIdx !== -1
|
|
292
|
+
? args[dirIdx + 1].split(",").map(d => path.resolve(cwd, d.trim()))
|
|
293
|
+
: ["src", "lib", "app", "api", "pages", "routes", "controllers", "handlers", "services"]
|
|
294
|
+
.map(d => path.join(cwd, d))
|
|
295
|
+
.filter(d => fs.existsSync(d));
|
|
296
|
+
|
|
297
|
+
if (!scanDirs.length) {
|
|
298
|
+
// Watch the whole project root as fallback
|
|
299
|
+
scanDirs.push(cwd);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const opts = { silent, noSuggest, noContext, interval };
|
|
303
|
+
const session = new VibeSession();
|
|
304
|
+
|
|
305
|
+
// Snapshot initial state
|
|
306
|
+
session.capsAtStart = readContractCapCount(infernoDir);
|
|
307
|
+
session.capsNow = session.capsAtStart;
|
|
308
|
+
session.scoreAtStart = readHealthScore(cwd);
|
|
309
|
+
|
|
310
|
+
if (!silent) {
|
|
311
|
+
console.log();
|
|
312
|
+
console.log(` ${bold("🔥 infernoflow vibe")} ${gray("— vibe coding mode active")}`);
|
|
313
|
+
console.log();
|
|
314
|
+
console.log(` ${bold("Watching:")} ${scanDirs.map(d => path.relative(cwd, d) || ".").join(", ")}`);
|
|
315
|
+
console.log(` ${bold("Debounce:")} ${interval}s`);
|
|
316
|
+
if (noSuggest) console.log(` ${gray("--no-suggest: contract sync disabled")}`);
|
|
317
|
+
if (noContext) console.log(` ${gray("--no-context: CONTEXT.md refresh disabled")}`);
|
|
318
|
+
console.log();
|
|
319
|
+
console.log(` ${cyan(String(session.capsAtStart))} capabilities in contract`);
|
|
320
|
+
if (session.scoreAtStart !== null) console.log(` Health score: ${cyan(String(session.scoreAtStart))}/100`);
|
|
321
|
+
console.log();
|
|
322
|
+
console.log(` ${gray("Watching for file saves… Press Ctrl+C to stop and see session summary.")}`);
|
|
323
|
+
console.log();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Optional mini dashboard
|
|
327
|
+
if (port) {
|
|
328
|
+
startMiniDashboard(port, cwd, infernoDir);
|
|
329
|
+
if (!silent) info(`Mini dashboard → http://localhost:${port}`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Start watching
|
|
333
|
+
const watchers = watchDirs(scanDirs, cwd, infernoDir, opts, session);
|
|
334
|
+
|
|
335
|
+
if (!watchers.length) {
|
|
336
|
+
warn("No directories to watch. Use --dir src,lib,api");
|
|
337
|
+
process.exit(1);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Spinner to show we're alive
|
|
341
|
+
let spinTimer = null;
|
|
342
|
+
if (!silent) {
|
|
343
|
+
let idleMsg = "Watching…";
|
|
344
|
+
spinTimer = setInterval(() => {
|
|
345
|
+
tick(idleMsg, gray);
|
|
346
|
+
}, 150);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Graceful shutdown
|
|
350
|
+
const shutdown = async () => {
|
|
351
|
+
if (spinTimer) clearInterval(spinTimer);
|
|
352
|
+
process.stdout.write("\r \r");
|
|
353
|
+
watchers.forEach(w => { try { w.close(); } catch {} });
|
|
354
|
+
session.scoreNow = readHealthScore(cwd);
|
|
355
|
+
session.capsNow = readContractCapCount(infernoDir);
|
|
356
|
+
if (!silent) session.printSummary();
|
|
357
|
+
process.exit(0);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
process.on("SIGINT", shutdown);
|
|
361
|
+
process.on("SIGTERM", shutdown);
|
|
362
|
+
|
|
363
|
+
// Keep alive
|
|
364
|
+
await new Promise(() => {});
|
|
365
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infernoflow project templates
|
|
3
|
+
*
|
|
4
|
+
* Named starters for `infernoflow init --template <name>`.
|
|
5
|
+
* Each template provides:
|
|
6
|
+
* - capabilities : pre-populated capability list
|
|
7
|
+
* - scenarios : one starter scenario per capability
|
|
8
|
+
* - contextHint : first-session CONTEXT.md guidance
|
|
9
|
+
* - scripts : suggested package.json scripts to add
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const TEMPLATES = {
|
|
13
|
+
|
|
14
|
+
// ── REST API ────────────────────────────────────────────────────────────────
|
|
15
|
+
"rest-api": {
|
|
16
|
+
description: "Express / Fastify / Hono REST API",
|
|
17
|
+
capabilities: [
|
|
18
|
+
{ id: "list-items", description: "GET /items — paginated list with filtering and sorting" },
|
|
19
|
+
{ id: "get-item", description: "GET /items/:id — fetch a single resource by ID" },
|
|
20
|
+
{ id: "create-item", description: "POST /items — create a new resource, validate input" },
|
|
21
|
+
{ id: "update-item", description: "PUT /items/:id — full update of an existing resource" },
|
|
22
|
+
{ id: "delete-item", description: "DELETE /items/:id — soft or hard delete" },
|
|
23
|
+
{ id: "authenticate", description: "POST /auth/login — exchange credentials for a JWT token" },
|
|
24
|
+
{ id: "refresh-token", description: "POST /auth/refresh — extend session with a new token" },
|
|
25
|
+
{ id: "health-check", description: "GET /health — liveness probe for load balancers" },
|
|
26
|
+
{ id: "paginate", description: "Cursor or offset pagination applied across list endpoints" },
|
|
27
|
+
{ id: "validate-input", description: "Schema validation on all incoming request bodies" },
|
|
28
|
+
],
|
|
29
|
+
contextHint: "Building a REST API. Focus on route handlers, middleware, and input validation.",
|
|
30
|
+
scripts: {
|
|
31
|
+
"dev": "node src/index.js",
|
|
32
|
+
"start": "node dist/index.js",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
// ── Next.js ─────────────────────────────────────────────────────────────────
|
|
37
|
+
"nextjs": {
|
|
38
|
+
description: "Next.js full-stack app (App Router or Pages Router)",
|
|
39
|
+
capabilities: [
|
|
40
|
+
{ id: "render-home-page", description: "/ — server-rendered landing page with SEO metadata" },
|
|
41
|
+
{ id: "render-dashboard", description: "/dashboard — authenticated user dashboard" },
|
|
42
|
+
{ id: "api-authenticate", description: "POST /api/auth/login — issue session cookie or JWT" },
|
|
43
|
+
{ id: "api-list-resources", description: "GET /api/resources — paginated list, auth-protected" },
|
|
44
|
+
{ id: "api-create-resource", description: "POST /api/resources — create and persist a resource" },
|
|
45
|
+
{ id: "server-side-props", description: "Fetch user-specific data server-side before render" },
|
|
46
|
+
{ id: "static-generation", description: "Pre-render marketing pages at build time" },
|
|
47
|
+
{ id: "image-optimization", description: "next/image usage for responsive, lazy-loaded images" },
|
|
48
|
+
{ id: "middleware-auth", description: "Edge middleware to protect dashboard routes" },
|
|
49
|
+
{ id: "error-boundary", description: "Global error.tsx / _error.tsx user-facing error page" },
|
|
50
|
+
],
|
|
51
|
+
contextHint: "Building a Next.js app. Use Server Components by default; Client Components only when needed.",
|
|
52
|
+
scripts: {
|
|
53
|
+
"dev": "next dev",
|
|
54
|
+
"build": "next build",
|
|
55
|
+
"start": "next start",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
// ── CLI tool ────────────────────────────────────────────────────────────────
|
|
60
|
+
"cli": {
|
|
61
|
+
description: "Node.js / Python CLI tool",
|
|
62
|
+
capabilities: [
|
|
63
|
+
{ id: "parse-args", description: "Parse CLI arguments and flags, show help on --help" },
|
|
64
|
+
{ id: "validate-config", description: "Load and validate config file or env vars on startup" },
|
|
65
|
+
{ id: "main-command", description: "Primary command — the core action the CLI performs" },
|
|
66
|
+
{ id: "output-formatter", description: "Format output as text, JSON, or table based on --format flag" },
|
|
67
|
+
{ id: "error-handler", description: "Friendly error messages with exit codes, no raw stack traces" },
|
|
68
|
+
{ id: "progress-display", description: "Spinner or progress bar for long-running operations" },
|
|
69
|
+
{ id: "update-check", description: "Check npm/PyPI for a newer version on startup" },
|
|
70
|
+
{ id: "plugin-loader", description: "Discover and load user-installed plugins at runtime" },
|
|
71
|
+
],
|
|
72
|
+
contextHint: "Building a CLI tool. Prioritise UX: helpful errors, --json flag, zero-config defaults.",
|
|
73
|
+
scripts: {
|
|
74
|
+
"start": "node bin/cli.js",
|
|
75
|
+
"link": "npm link",
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
// ── GraphQL ─────────────────────────────────────────────────────────────────
|
|
80
|
+
"graphql": {
|
|
81
|
+
description: "GraphQL API (Apollo, Pothos, or similar)",
|
|
82
|
+
capabilities: [
|
|
83
|
+
{ id: "query-viewer", description: "query { viewer } — return the authenticated user" },
|
|
84
|
+
{ id: "query-list", description: "query { items(first: N, after: cursor) } — paginated list" },
|
|
85
|
+
{ id: "query-node", description: "query { node(id) } — global object lookup by ID" },
|
|
86
|
+
{ id: "mutation-create", description: "mutation { createItem(input) } — create and return new object" },
|
|
87
|
+
{ id: "mutation-update", description: "mutation { updateItem(id, input) } — update fields" },
|
|
88
|
+
{ id: "mutation-delete", description: "mutation { deleteItem(id) } — remove and return deleted" },
|
|
89
|
+
{ id: "subscription-event", description: "subscription { itemUpdated } — real-time push via WebSocket" },
|
|
90
|
+
{ id: "data-loader", description: "Batch and cache DB calls with DataLoader to avoid N+1" },
|
|
91
|
+
{ id: "auth-directive", description: "@auth directive — enforce authentication on resolvers" },
|
|
92
|
+
{ id: "error-formatting", description: "Consistent GraphQL error shape with extensions and codes" },
|
|
93
|
+
],
|
|
94
|
+
contextHint: "Building a GraphQL API. Use DataLoader for all relations, @auth directive on protected resolvers.",
|
|
95
|
+
scripts: {
|
|
96
|
+
"dev": "ts-node src/server.ts",
|
|
97
|
+
"codegen": "graphql-codegen",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// ── Monorepo ────────────────────────────────────────────────────────────────
|
|
102
|
+
"monorepo": {
|
|
103
|
+
description: "Monorepo workspace (nx / turborepo / pnpm workspaces)",
|
|
104
|
+
capabilities: [
|
|
105
|
+
{ id: "build-all", description: "Build all packages in dependency order" },
|
|
106
|
+
{ id: "test-all", description: "Run tests across all packages in parallel" },
|
|
107
|
+
{ id: "lint-all", description: "Lint all packages with shared ESLint / Prettier config" },
|
|
108
|
+
{ id: "publish-packages", description: "Bump versions and publish changed packages to npm" },
|
|
109
|
+
{ id: "shared-ui-library", description: "packages/ui — shared React component library" },
|
|
110
|
+
{ id: "shared-utils", description: "packages/utils — shared utility functions and types" },
|
|
111
|
+
{ id: "web-app", description: "apps/web — primary Next.js or React web application" },
|
|
112
|
+
{ id: "api-service", description: "apps/api — backend service consumed by apps" },
|
|
113
|
+
{ id: "cache-builds", description: "Remote build cache via Turborepo or Nx Cloud" },
|
|
114
|
+
{ id: "affected-only", description: "Run tasks only for packages affected by a git change" },
|
|
115
|
+
],
|
|
116
|
+
contextHint: "Working in a monorepo. Changes in packages/* may affect apps/*. Check affected packages before building.",
|
|
117
|
+
scripts: {
|
|
118
|
+
"build": "turbo build",
|
|
119
|
+
"dev": "turbo dev",
|
|
120
|
+
"test": "turbo test",
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export function listTemplates() {
|
|
126
|
+
return Object.entries(TEMPLATES).map(([name, t]) => ({ name, description: t.description }));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getTemplate(name) {
|
|
130
|
+
return TEMPLATES[name] || null;
|
|
131
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infernoflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "The forge for liquid code - keep capabilities, contracts, and docs in sync.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"dist/lib",
|
|
15
15
|
"dist/templates",
|
|
16
16
|
"README.md",
|
|
17
|
-
"CHANGELOG.md"
|
|
17
|
+
"CHANGELOG.md",
|
|
18
|
+
"dist/lib/templates"
|
|
18
19
|
],
|
|
19
20
|
"scripts": {
|
|
20
21
|
"test": "node scripts/smoke.mjs && node scripts/json-smoke.mjs && node scripts/json-negative-smoke.mjs && node scripts/implement-smoke.mjs && node scripts/adopt-smoke.mjs && node scripts/pr-impact-smoke.mjs && node scripts/sync-smoke.mjs && node scripts/run-smoke.mjs",
|