infernoflow 0.10.8 → 0.10.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,17 @@ npx infernoflow init
28
28
  # 1. Scaffold in your project root:
29
29
  npx infernoflow init
30
30
 
31
+ # Optional: same as init, plus Cursor hooks that append each Agent reply
32
+ # to inferno/CONTEXT.draft.md (gitignored). Promote with npm run inferno:promote-draft.
33
+ npx infernoflow init --yes --cursor-hooks
34
+
35
+ # Optional: VS Code + GitHub Copilot agent hooks (Preview) — same draft file + promote flow
36
+ npx infernoflow init --yes --vscode-copilot-hooks
37
+
38
+ # Or add hooks to an existing infernoflow project:
39
+ npx infernoflow install-cursor-hooks
40
+ npx infernoflow install-vscode-copilot-hooks
41
+
31
42
  # 2. See your contract health:
32
43
  infernoflow status
33
44
 
@@ -172,11 +183,38 @@ infernoflow doc-gate --json
172
183
  | `infernoflow check` | Full validation: contract, capabilities, scenarios, changelog |
173
184
  | `infernoflow doc-gate` | Fails if code changed but docs weren't updated |
174
185
  | `infernoflow context` | Build/persist AI session context for this project |
186
+ | `infernoflow install-cursor-hooks` | Install `.cursor/hooks` + `scripts/inferno-promote-draft.mjs` (Agent → draft → promote) |
187
+ | `infernoflow install-vscode-copilot-hooks` | Install `.github/hooks` for VS Code + Copilot (Preview) + same promote script |
188
+
189
+ ### Cursor hooks (draft → promote)
190
+
191
+ [Cursor hooks](https://cursor.com/docs/agent/hooks) can run a small Node script after each assistant message. infernoflow can install:
192
+
193
+ - **`.cursor/hooks.json`** — `afterAgentResponse` and `stop` events
194
+ - **`.cursor/hooks/inferno-session-draft.mjs`** — appends assistant `text` to **`inferno/CONTEXT.draft.md`** (never overwrites `CONTEXT.md` automatically)
195
+ - **`scripts/inferno-promote-draft.mjs`** + **`npm run inferno:promote-draft`** — preview, `--append-notes` (merge under `## Decisions & notes` in `inferno/CONTEXT.md`), or `--clear`
196
+ - **`.gitignore`** entry for `inferno/CONTEXT.draft.md` when possible
197
+
198
+ Install with **`infernoflow install-cursor-hooks`** or **`infernoflow init --cursor-hooks`**. Restart Cursor after install. Review the draft before promoting; treat chat as **input**, not product truth.
199
+
200
+ ### VS Code + GitHub Copilot hooks (draft → promote, Preview)
201
+
202
+ VS Code can run [Agent hooks](https://code.visualstudio.com/docs/copilot/customization/hooks) from **`.github/hooks/*.json`**. infernoflow installs:
203
+
204
+ - **`.github/hooks/infernoflow-drafts.json`** — wires **`UserPromptSubmit`** (your prompt) and **`Stop`** (end of agent turn)
205
+ - **`scripts/inferno-vscode-copilot-hook.mjs`** — appends the user prompt on submit; on **Stop**, reads **`transcript_path`** from stdin (when present), parses **JSONL** or session **JSON**, and appends the **last assistant text** it can infer (format varies by VS Code / Copilot version — if parsing fails, a short marker is still appended)
206
+ - The same **`inferno:promote-draft`** script and **`.gitignore`** entry for **`inferno/CONTEXT.draft.md`** as the Cursor flow
207
+
208
+ Install with **`infernoflow install-vscode-copilot-hooks`** or **`infernoflow init --vscode-copilot-hooks`**. Restart VS Code, confirm your org allows hooks, and use the **GitHub Copilot Chat Hooks** output channel for diagnostics.
209
+
210
+ **Limitations:** Hooks are **Preview**; `transcript_path` / JSONL shape may differ by build; some hook events omit `transcript_path` ([vscode#300583](https://github.com/microsoft/vscode/issues/300583)). You still have the full **`infernoflow`** CLI in the terminal when hooks are not enough.
175
211
 
176
212
  ### Options
177
213
 
178
214
  ```bash
179
215
  infernoflow init --force # overwrite existing files
216
+ infernoflow init --cursor-hooks # with init: install Cursor draft hooks (see above)
217
+ infernoflow init --vscode-copilot-hooks # with init: install VS Code + Copilot draft hooks (Preview)
180
218
  infernoflow init --yes # skip prompts, use defaults
181
219
  infernoflow init --adopt # infer baseline from existing project
182
220
  infernoflow init --adopt --lang ts --framework react --project-type frontend
@@ -194,6 +232,8 @@ infernoflow pr-impact --json
194
232
  infernoflow sync --auto
195
233
  infernoflow sync --auto --json
196
234
  npm run inferno:hooks # install local git hooks (after init)
235
+ infernoflow install-cursor-hooks --force # overwrite hook files if present
236
+ infernoflow install-vscode-copilot-hooks --force
197
237
  infernoflow check --json # machine-readable output for CI
198
238
  infernoflow check --skip-doc-gate
199
239
  infernoflow status --json # machine-readable status summary
@@ -240,10 +280,11 @@ Works with any AI — Claude, ChatGPT, GitHub Copilot, Cursor, or your own setup
240
280
 
241
281
  Run one command and infernoflow will:
242
282
  1. Detect drift (`pr-impact`)
243
- 2. Generate suggestion via local model
244
- 3. Apply inferno updates
245
- 4. Validate with `check`
246
- 5. Roll back automatically if validation fails
283
+ 2. Resolve provider (`auto` defaults to IDE agent)
284
+ 3. Generate suggestion
285
+ 4. Apply inferno updates
286
+ 5. Validate with `check`
287
+ 6. Roll back automatically if validation fails
247
288
 
248
289
  ```bash
249
290
  infernoflow run "add favorite badge to tasks and filter by favorite"
@@ -255,10 +296,24 @@ Machine mode:
255
296
  infernoflow run "sync check" --json
256
297
  ```
257
298
 
258
- Local model configuration (required):
299
+ Provider options:
300
+
301
+ ```bash
302
+ infernoflow run "task" --provider auto # default (IDE agent first)
303
+ infernoflow run "task" --provider agent --ide cursor # require IDE agent
304
+ infernoflow run "task" --provider local # explicit local model
305
+ infernoflow run "task" --provider prompt # deterministic prompt fallback
306
+ ```
307
+
308
+ IDE routing behavior:
309
+ - `auto` + agent available -> uses IDE agent
310
+ - `auto` + no agent -> falls back to prompt mode (`FALLBACK_PROMPT_MODE`)
311
+ - `agent` + no agent -> exits with `EXPLICIT_AGENT_REQUIRED`
312
+
313
+ Local model configuration (optional):
259
314
 
260
315
  ```bash
261
- # default provider: ollama
316
+ # local provider example: ollama
262
317
  set INFERNO_LOCAL_PROVIDER=ollama
263
318
  set INFERNO_LOCAL_ENDPOINT=http://127.0.0.1:11434/api/generate
264
319
  set INFERNO_LOCAL_MODEL=llama3.1:8b
@@ -320,12 +375,9 @@ Recommended chain:
320
375
 
321
376
  ```yaml
322
377
  # .github/workflows/ci.yml
323
- - name: infernoflow run
324
- run: npx infernoflow run "sync check" --json
378
+ - name: infernoflow run (headless)
379
+ run: npx infernoflow run "sync check" --provider prompt --json
325
380
  env:
326
- INFERNO_LOCAL_PROVIDER: ollama
327
- INFERNO_LOCAL_ENDPOINT: http://127.0.0.1:11434/api/generate
328
- INFERNO_LOCAL_MODEL: llama3.1:8b
329
381
  BASE_SHA: ${{ github.event.pull_request.base.sha }}
330
382
  HEAD_SHA: ${{ github.event.pull_request.head.sha }}
331
383
  ```
@@ -9,6 +9,9 @@ const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf8
9
9
  const VERSION = pkg.version || "0.0.0";
10
10
  const COMMAND_DESCRIPTIONS = {
11
11
  init: "Scaffold inferno/ in your project (or adopt existing project)",
12
+ "install-cursor-hooks": "Install Cursor hooks: draft agent replies to inferno/CONTEXT.draft.md",
13
+ "install-vscode-copilot-hooks":
14
+ "Install VS Code + Copilot agent hooks (Preview): draft to inferno/CONTEXT.draft.md",
12
15
  check: "Validate contract, capabilities, scenarios, changelog",
13
16
  status: "Show contract health at a glance",
14
17
  "pr-impact": "Summarize PR impact on capabilities and docs",
@@ -22,6 +25,10 @@ const COMMAND_DESCRIPTIONS = {
22
25
 
23
26
  const COMMAND_HANDLERS = {
24
27
  init: async (args) => (await import("../lib/commands/init.mjs")).initCommand(args),
28
+ "install-cursor-hooks": async (args) =>
29
+ (await import("../lib/commands/installCursorHooks.mjs")).installCursorHooksCommand(args),
30
+ "install-vscode-copilot-hooks": async (args) =>
31
+ (await import("../lib/commands/installVsCodeCopilotHooks.mjs")).installVsCodeCopilotHooksCommand(args),
25
32
  check: async (args) => (await import("../lib/commands/check.mjs")).checkCommand(args),
26
33
  status: async (args) => (await import("../lib/commands/status.mjs")).statusCommand(args),
27
34
  "pr-impact": async (args) => (await import("../lib/commands/prImpact.mjs")).prImpactCommand(args),
@@ -34,8 +41,10 @@ const COMMAND_HANDLERS = {
34
41
  };
35
42
 
36
43
  function formatCommandsHelp() {
44
+ const names = Object.keys(COMMAND_DESCRIPTIONS);
45
+ const w = Math.max(...names.map((n) => n.length), 8) + 1;
37
46
  return Object.entries(COMMAND_DESCRIPTIONS)
38
- .map(([name, desc]) => ` ${name.padEnd(13, " ")}${desc}`)
47
+ .map(([name, desc]) => ` ${name.padEnd(w, " ")}${desc}`)
39
48
  .join("\n");
40
49
  }
41
50
 
@@ -50,6 +59,8 @@ const HELP = `
50
59
  ${formatCommandsHelp()}
51
60
 
52
61
  ${bold("init options:")}
62
+ --cursor-hooks Also install Cursor hooks (draft → inferno/CONTEXT.draft.md)
63
+ --vscode-copilot-hooks Also install VS Code + Copilot hooks (.github/hooks — Preview)
53
64
  --adopt Infer capabilities from an existing codebase
54
65
  --lang <name> Override detected language (e.g. ts, js, py)
55
66
  --framework <name> Override detected framework (e.g. react, angular, express)
@@ -60,6 +71,12 @@ ${formatCommandsHelp()}
60
71
  --yes, -y Skip prompts and accept inferred/default values
61
72
  --force, -f Overwrite existing inferno/ files
62
73
 
74
+ ${bold("install-cursor-hooks options:")}
75
+ --force, -f Overwrite .cursor/hooks.json and hook scripts if they exist
76
+
77
+ ${bold("install-vscode-copilot-hooks options:")}
78
+ --force, -f Overwrite .github/hooks/infernoflow-drafts.json and scripts if they exist
79
+
63
80
  ${bold("context options:")}
64
81
  --intent "..." What you plan to build next
65
82
  --working "..." What you are building right now
@@ -76,6 +93,8 @@ ${formatCommandsHelp()}
76
93
  --dry-run Execute full flow without writing files
77
94
  --json Emit machine-readable events and result payload
78
95
  --no-rollback Keep changes even if validation fails
96
+ --provider <type> auto | agent | local | prompt (default: auto)
97
+ --ide <name> auto | cursor | vscode | windsurf (default: auto)
79
98
 
80
99
  ${bold("Typical workflow:")}
81
100
  ${gray('1. infernoflow context --intent "what I want to build"')}
@@ -0,0 +1,31 @@
1
+ export function detectIdeContext(preferredIde = "auto") {
2
+ const env = process.env;
3
+ const lowerPreferred = String(preferredIde || "auto").toLowerCase();
4
+
5
+ const hasCursor = !!(env.CURSOR_TRACE_ID || env.CURSOR_AGENT || env.CURSOR_SESSION_ID);
6
+ const hasVscode = !!(env.VSCODE_PID || env.VSCODE_CWD || env.GITHUB_COPILOT_AGENT);
7
+ const hasWindsurf = !!(env.WINDSURF || env.CODEIUM || env.WINDSURF_SESSION_ID);
8
+
9
+ let ideDetected = "unknown";
10
+ if (hasCursor) ideDetected = "cursor";
11
+ else if (hasVscode) ideDetected = "vscode";
12
+ else if (hasWindsurf) ideDetected = "windsurf";
13
+
14
+ if (lowerPreferred !== "auto" && ["cursor", "vscode", "windsurf"].includes(lowerPreferred)) {
15
+ ideDetected = lowerPreferred;
16
+ }
17
+
18
+ const explicitAgentAvailability = env.INFERNO_AGENT_AVAILABLE;
19
+ const agentAvailable = explicitAgentAvailability != null
20
+ ? explicitAgentAvailability === "1" || explicitAgentAvailability === "true"
21
+ : ideDetected !== "unknown";
22
+
23
+ const reasonCodes = [];
24
+ if (ideDetected !== "unknown") reasonCodes.push(`IDE_${ideDetected.toUpperCase()}_DETECTED`);
25
+ else reasonCodes.push("IDE_UNKNOWN");
26
+ if (agentAvailable) reasonCodes.push("IDE_AGENT_AVAILABLE");
27
+ else reasonCodes.push("IDE_AGENT_UNAVAILABLE");
28
+
29
+ return { ideDetected, agentAvailable, reasonCodes };
30
+ }
31
+
@@ -0,0 +1,73 @@
1
+ import { detectIdeContext } from "./ideDetection.mjs";
2
+
3
+ export async function resolveProvider(requestedProvider = "auto", preferredIde = "auto") {
4
+ const providerRequested = String(requestedProvider || "auto").toLowerCase();
5
+ const ide = detectIdeContext(preferredIde);
6
+ const reasonCodes = [...ide.reasonCodes];
7
+
8
+ if (providerRequested === "local") {
9
+ reasonCodes.push("LOCAL_PROVIDER_SELECTED");
10
+ return {
11
+ providerRequested,
12
+ providerResolved: "local",
13
+ ideDetected: ide.ideDetected,
14
+ agentAvailable: ide.agentAvailable,
15
+ reasonCodes,
16
+ };
17
+ }
18
+
19
+ if (providerRequested === "prompt") {
20
+ reasonCodes.push("PROMPT_PROVIDER_SELECTED");
21
+ return {
22
+ providerRequested,
23
+ providerResolved: "prompt",
24
+ ideDetected: ide.ideDetected,
25
+ agentAvailable: ide.agentAvailable,
26
+ reasonCodes,
27
+ };
28
+ }
29
+
30
+ if (providerRequested === "agent") {
31
+ if (!ide.agentAvailable) {
32
+ reasonCodes.push("EXPLICIT_AGENT_REQUIRED");
33
+ return {
34
+ providerRequested,
35
+ providerResolved: "none",
36
+ ideDetected: ide.ideDetected,
37
+ agentAvailable: ide.agentAvailable,
38
+ reasonCodes,
39
+ error: "agent_unavailable",
40
+ };
41
+ }
42
+ reasonCodes.push("IDE_AGENT_SELECTED");
43
+ return {
44
+ providerRequested,
45
+ providerResolved: "agent",
46
+ ideDetected: ide.ideDetected,
47
+ agentAvailable: ide.agentAvailable,
48
+ reasonCodes,
49
+ };
50
+ }
51
+
52
+ // auto
53
+ if (ide.agentAvailable) {
54
+ reasonCodes.push("IDE_AGENT_SELECTED");
55
+ return {
56
+ providerRequested: "auto",
57
+ providerResolved: "agent",
58
+ ideDetected: ide.ideDetected,
59
+ agentAvailable: ide.agentAvailable,
60
+ reasonCodes,
61
+ };
62
+ }
63
+
64
+ reasonCodes.push("FALLBACK_PROMPT_MODE");
65
+ return {
66
+ providerRequested: "auto",
67
+ providerResolved: "prompt",
68
+ ideDetected: ide.ideDetected,
69
+ agentAvailable: ide.agentAvailable,
70
+ reasonCodes,
71
+ };
72
+ }
73
+
@@ -11,6 +11,8 @@ import {
11
11
  summarizeCapabilities,
12
12
  buildSignalsReport,
13
13
  } from "./adopt.mjs";
14
+ import { installCursorHooksArtifacts } from "../cursorHooksInstall.mjs";
15
+ import { installVsCodeCopilotHooksArtifacts } from "../vsCodeCopilotHooksInstall.mjs";
14
16
 
15
17
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
18
 
@@ -68,7 +70,7 @@ function upsertScripts(cwd, silent = false) {
68
70
  "inferno:gate": "infernoflow doc-gate",
69
71
  "inferno:impact": "infernoflow pr-impact --json",
70
72
  "inferno:sync": "infernoflow sync --auto --json",
71
- "inferno:run": "infernoflow run \"sync check\" --json",
73
+ "inferno:run": "infernoflow run \"sync check\" --provider auto --json",
72
74
  "inferno:hooks": "node scripts/inferno-install-hooks.mjs"
73
75
  };
74
76
  for (const [k, v] of Object.entries(toAdd)) {
@@ -153,6 +155,8 @@ export async function initCommand(args) {
153
155
  const force = args.includes("--force") || args.includes("-f");
154
156
  const yes = args.includes("--yes") || args.includes("-y");
155
157
  const adopt = args.includes("--adopt");
158
+ const cursorHooks = args.includes("--cursor-hooks");
159
+ const vscodeCopilotHooks = args.includes("--vscode-copilot-hooks");
156
160
  const reportJson = args.includes("--report-json");
157
161
  const reportJsonOnly = args.includes("--report-json-only");
158
162
  const reportHumanOnly = args.includes("--report-human-only");
@@ -317,6 +321,35 @@ export async function initCommand(args) {
317
321
 
318
322
  upsertScripts(cwd, silent);
319
323
 
324
+ if (cursorHooks) {
325
+ installCursorHooksArtifacts({
326
+ cwd,
327
+ templatesRoot: templates,
328
+ force,
329
+ silent,
330
+ logOk: (msg) => {
331
+ if (!silent) ok(msg);
332
+ },
333
+ logWarn: (msg) => {
334
+ if (!silent) warn(msg);
335
+ },
336
+ });
337
+ }
338
+ if (vscodeCopilotHooks) {
339
+ installVsCodeCopilotHooksArtifacts({
340
+ cwd,
341
+ templatesRoot: templates,
342
+ force,
343
+ silent,
344
+ logOk: (msg) => {
345
+ if (!silent) ok(msg);
346
+ },
347
+ logWarn: (msg) => {
348
+ if (!silent) warn(msg);
349
+ },
350
+ });
351
+ }
352
+
320
353
  if (adopt) {
321
354
  const statePath = path.join(infernoDir, "context-state.json");
322
355
  let state = {};
@@ -341,7 +374,28 @@ export async function initCommand(args) {
341
374
  cyan("infernoflow check") + " — validate everything",
342
375
  (adopt ? "Review inferred baseline in " : "Edit ") + yellow("inferno/capabilities.json") + (adopt ? " and refine IDs/titles" : " to describe each capability in detail"),
343
376
  "Add more " + yellow("inferno/scenarios/*.json") + " files for edge cases",
344
- "Add " + cyan("inferno:check") + " to your CI pipeline"
377
+ "Add " + cyan("inferno:check") + " to your CI pipeline",
378
+ ...(cursorHooks
379
+ ? [
380
+ "Restart Cursor — hooks write assistant text to " + yellow("inferno/CONTEXT.draft.md"),
381
+ "Promote when ready: " + cyan("npm run inferno:promote-draft -- --append-notes"),
382
+ ]
383
+ : []),
384
+ ...(vscodeCopilotHooks
385
+ ? [
386
+ "Restart VS Code — Copilot hooks append prompts + assistant (from transcript) to " +
387
+ yellow("inferno/CONTEXT.draft.md"),
388
+ "Promote when ready: " + cyan("npm run inferno:promote-draft -- --append-notes"),
389
+ ]
390
+ : []),
391
+ ...(!cursorHooks && !vscodeCopilotHooks
392
+ ? [
393
+ "Optional: " +
394
+ cyan("infernoflow install-cursor-hooks") +
395
+ " or " +
396
+ cyan("infernoflow install-vscode-copilot-hooks"),
397
+ ]
398
+ : []),
345
399
  ]);
346
400
  }
347
401
  }
@@ -0,0 +1,36 @@
1
+ import * as path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { header, ok, warn, done, nextSteps, cyan, yellow } from "../ui/output.mjs";
4
+ import { installCursorHooksArtifacts } from "../cursorHooksInstall.mjs";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ function getTemplatesRoot() {
9
+ return path.resolve(__dirname, "../../templates");
10
+ }
11
+
12
+ export async function installCursorHooksCommand(args) {
13
+ const cwd = process.cwd();
14
+ const force = args.includes("--force") || args.includes("-f");
15
+
16
+ header("install-cursor-hooks");
17
+
18
+ installCursorHooksArtifacts({
19
+ cwd,
20
+ templatesRoot: getTemplatesRoot(),
21
+ force,
22
+ silent: false,
23
+ logOk: (msg) => ok(msg),
24
+ logWarn: (msg) => warn(msg),
25
+ });
26
+
27
+ done("Cursor draft hooks installed");
28
+
29
+ nextSteps([
30
+ "Restart Cursor (or reload window) so " + yellow(".cursor/hooks.json") + " is picked up",
31
+ "Use Agent chat — each assistant reply appends to " + yellow("inferno/CONTEXT.draft.md") + " (gitignored)",
32
+ cyan("npm run inferno:promote-draft") + " — preview draft",
33
+ cyan("npm run inferno:promote-draft -- --append-notes") + " — merge into inferno/CONTEXT.md under Decisions",
34
+ cyan("npm run inferno:promote-draft -- --clear") + " — discard draft",
35
+ ]);
36
+ }
@@ -0,0 +1,37 @@
1
+ import * as path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { header, ok, warn, done, nextSteps, cyan, yellow } from "../ui/output.mjs";
4
+ import { installVsCodeCopilotHooksArtifacts } from "../vsCodeCopilotHooksInstall.mjs";
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ function getTemplatesRoot() {
9
+ return path.resolve(__dirname, "../../templates");
10
+ }
11
+
12
+ export async function installVsCodeCopilotHooksCommand(args) {
13
+ const cwd = process.cwd();
14
+ const force = args.includes("--force") || args.includes("-f");
15
+
16
+ header("install-vscode-copilot-hooks");
17
+
18
+ installVsCodeCopilotHooksArtifacts({
19
+ cwd,
20
+ templatesRoot: getTemplatesRoot(),
21
+ force,
22
+ silent: false,
23
+ logOk: (msg) => ok(msg),
24
+ logWarn: (msg) => warn(msg),
25
+ });
26
+
27
+ done("VS Code / Copilot draft hooks installed");
28
+
29
+ nextSteps([
30
+ "Requires VS Code + GitHub Copilot and **Agent hooks (Preview)** — see " +
31
+ yellow("https://code.visualstudio.com/docs/copilot/customization/hooks"),
32
+ "Hooks load from " + yellow(".github/hooks/*.json") + " — restart VS Code or reload window after first install",
33
+ "Check the **GitHub Copilot Chat Hooks** output channel if nothing runs",
34
+ cyan("npm run inferno:promote-draft") + " — preview draft",
35
+ cyan("npm run inferno:promote-draft -- --append-notes") + " — merge into inferno/CONTEXT.md",
36
+ ]);
37
+ }
@@ -3,6 +3,7 @@ import * as path from "node:path";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { generateWithLocalModel } from "../ai/localProvider.mjs";
6
+ import { resolveProvider } from "../ai/providerRouter.mjs";
6
7
  import {
7
8
  buildPrompt,
8
9
  loadSuggestContext,
@@ -88,13 +89,59 @@ function writeRunArtifact(cwd, artifact) {
88
89
  return path.relative(cwd, filePath);
89
90
  }
90
91
 
92
+ function getOptionValue(args, flag, fallback = null) {
93
+ const idx = args.indexOf(flag);
94
+ if (idx !== -1 && args[idx + 1] && !args[idx + 1].startsWith("-")) return args[idx + 1];
95
+ return fallback;
96
+ }
97
+
98
+ function extractTask(args) {
99
+ const takesValue = new Set(["--provider", "--ide"]);
100
+ const out = [];
101
+ for (let i = 1; i < args.length; i++) {
102
+ const token = args[i];
103
+ if (token.startsWith("-")) {
104
+ if (takesValue.has(token)) i += 1;
105
+ continue;
106
+ }
107
+ out.push(token);
108
+ }
109
+ return out.join(" ").trim();
110
+ }
111
+
112
+ function buildPromptFallbackSuggestion(task, contract) {
113
+ return {
114
+ summary: `Prompt fallback only: ${task}`,
115
+ newCapabilities: [],
116
+ removedCapabilities: [],
117
+ updatedScenarios: [],
118
+ changelogEntry: `- Prompt fallback mode for task: ${task} (no automatic contract mutation).`,
119
+ _meta: {
120
+ actionRequired: true,
121
+ nextStep: "Run infernoflow suggest or provide an agent bridge for automatic apply.",
122
+ capabilitiesCount: (contract?.capabilities || []).length,
123
+ },
124
+ };
125
+ }
126
+
127
+ async function generateWithIdeAgent(prompt) {
128
+ if (process.env.INFERNO_AGENT_MOCK_RESPONSE) return process.env.INFERNO_AGENT_MOCK_RESPONSE;
129
+ if (process.env.INFERNO_AGENT_RESPONSE_FILE && fs.existsSync(process.env.INFERNO_AGENT_RESPONSE_FILE)) {
130
+ return fs.readFileSync(process.env.INFERNO_AGENT_RESPONSE_FILE, "utf8");
131
+ }
132
+ throw new Error("ide_agent_bridge_not_configured");
133
+ }
134
+
91
135
  export async function runCommand(args = []) {
92
136
  const asJson = args.includes("--json");
93
137
  const dryRun = args.includes("--dry-run");
94
138
  const noRollback = args.includes("--no-rollback");
95
- const task = args.filter((a) => !a.startsWith("-")).slice(1).join(" ").trim() || "sync check";
139
+ const providerRequested = (getOptionValue(args, "--provider", "auto") || "auto").toLowerCase();
140
+ const ideRequested = (getOptionValue(args, "--ide", "auto") || "auto").toLowerCase();
141
+ const task = extractTask(args) || "sync check";
96
142
  const cwd = process.cwd();
97
143
  const events = [];
144
+ const reasonCodes = [];
98
145
 
99
146
  if (!asJson) header("run");
100
147
  stageEvent(asJson, events, "init", "info", { task, dryRun, noRollback });
@@ -103,6 +150,30 @@ export async function runCommand(args = []) {
103
150
  const impact = runCliJson(["pr-impact", "--json"]);
104
151
  stageEvent(asJson, events, "detect", impact.data?.ok ? "ok" : "warn", { confidence: impact.data?.confidence || "low" });
105
152
 
153
+ const routed = await resolveProvider(providerRequested, ideRequested);
154
+ reasonCodes.push(...(routed.reasonCodes || []));
155
+ if (routed.error === "agent_unavailable") {
156
+ const payload = {
157
+ ok: false,
158
+ error: "agent_unavailable",
159
+ providerRequested,
160
+ providerResolved: routed.providerResolved,
161
+ ideDetected: routed.ideDetected,
162
+ agentAvailable: routed.agentAvailable,
163
+ reasonCodes,
164
+ events,
165
+ };
166
+ if (asJson) console.log(JSON.stringify(payload, null, 2));
167
+ else fail("provider agent unavailable", "Use --provider auto|local|prompt");
168
+ process.exit(1);
169
+ }
170
+ stageEvent(asJson, events, "route", "ok", {
171
+ providerRequested,
172
+ providerResolved: routed.providerResolved,
173
+ ideDetected: routed.ideDetected,
174
+ agentAvailable: routed.agentAvailable,
175
+ });
176
+
106
177
  const ctx = loadSuggestContext(cwd);
107
178
  if (!ctx?.contract) {
108
179
  const payload = { ok: false, error: "inferno_missing", events };
@@ -120,12 +191,19 @@ export async function runCommand(args = []) {
120
191
  });
121
192
  let suggestion;
122
193
  try {
123
- const raw = await generateWithLocalModel(prompt);
124
- suggestion = parseSuggestionJson(raw);
194
+ if (routed.providerResolved === "local") {
195
+ const raw = await generateWithLocalModel(prompt);
196
+ suggestion = parseSuggestionJson(raw);
197
+ } else if (routed.providerResolved === "agent") {
198
+ const raw = await generateWithIdeAgent(prompt);
199
+ suggestion = parseSuggestionJson(raw);
200
+ } else {
201
+ suggestion = buildPromptFallbackSuggestion(task, ctx.contract);
202
+ }
125
203
  } catch (err) {
126
- const payload = { ok: false, error: "local_model_failed", reason: String(err.message || err), events };
204
+ const payload = { ok: false, error: "proposal_failed", reason: String(err.message || err), reasonCodes, events };
127
205
  if (asJson) console.log(JSON.stringify(payload, null, 2));
128
- else fail(`local model failed`, err.message);
206
+ else fail("proposal generation failed", err.message);
129
207
  process.exit(1);
130
208
  }
131
209
  stageEvent(asJson, events, "propose", "ok", {
@@ -133,8 +211,8 @@ export async function runCommand(args = []) {
133
211
  removedCapabilities: (suggestion.removedCapabilities || []).length,
134
212
  });
135
213
 
136
- const schemaErrors = validateSuggestion(suggestion);
137
- const conflictErrors = detectSuggestionConflicts(ctx.contract, suggestion);
214
+ const schemaErrors = routed.providerResolved === "prompt" ? [] : validateSuggestion(suggestion);
215
+ const conflictErrors = routed.providerResolved === "prompt" ? [] : detectSuggestionConflicts(ctx.contract, suggestion);
138
216
  if (schemaErrors.length || conflictErrors.length) {
139
217
  const payload = {
140
218
  ok: false,
@@ -155,6 +233,11 @@ export async function runCommand(args = []) {
155
233
  try {
156
234
  if (dryRun) {
157
235
  stageEvent(asJson, events, "apply", "info", { dryRun: true });
236
+ } else if (routed.providerResolved === "prompt") {
237
+ stageEvent(asJson, events, "apply", "warn", {
238
+ skipped: true,
239
+ reason: "prompt_fallback_requires_manual_step",
240
+ });
158
241
  } else {
159
242
  applyChanged = applyChanges({
160
243
  cwd,
@@ -205,6 +288,11 @@ export async function runCommand(args = []) {
205
288
  mode: "run",
206
289
  task,
207
290
  dryRun,
291
+ providerRequested,
292
+ providerResolved: routed.providerResolved,
293
+ ideDetected: routed.ideDetected,
294
+ agentAvailable: routed.agentAvailable,
295
+ reasonCodes: Array.from(new Set(reasonCodes)),
208
296
  rolledBack,
209
297
  applyChanged,
210
298
  artifactPath,
@@ -0,0 +1,39 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { installInfernoDraftTooling } from "./draftToolingInstall.mjs";
4
+
5
+ /**
6
+ * @param {object} opts
7
+ * @param {string} opts.cwd
8
+ * @param {string} opts.templatesRoot
9
+ * @param {boolean} opts.force
10
+ * @param {boolean} opts.silent
11
+ * @param {(msg: string) => void} [opts.logOk]
12
+ * @param {(msg: string) => void} [opts.logWarn]
13
+ */
14
+ export function installCursorHooksArtifacts(opts) {
15
+ const { cwd, templatesRoot, force, silent } = opts;
16
+ const logOk = opts.logOk || (() => {});
17
+ const logWarn = opts.logWarn || (() => {});
18
+
19
+ function copyFile(src, dst) {
20
+ if (fs.existsSync(dst) && !force) {
21
+ if (!silent) logWarn("Skipped (exists): " + path.relative(cwd, dst));
22
+ return false;
23
+ }
24
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
25
+ fs.copyFileSync(src, dst);
26
+ if (!silent) logOk("Created: " + path.relative(cwd, dst));
27
+ return true;
28
+ }
29
+
30
+ installInfernoDraftTooling({ cwd, templatesRoot, force, silent, logOk, logWarn });
31
+
32
+ const srcHooksJson = path.join(templatesRoot, "cursor", "hooks.json");
33
+ const dstHooksJson = path.join(cwd, ".cursor", "hooks.json");
34
+ const srcHook = path.join(templatesRoot, "cursor", "hooks", "inferno-session-draft.mjs");
35
+ const dstHook = path.join(cwd, ".cursor", "hooks", "inferno-session-draft.mjs");
36
+
37
+ copyFile(srcHooksJson, dstHooksJson);
38
+ copyFile(srcHook, dstHook);
39
+ }
@@ -0,0 +1,69 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+
4
+ const GITIGNORE_SNIPPET = `
5
+ # infernoflow: agent draft (IDE hooks — review before commit)
6
+ inferno/CONTEXT.draft.md
7
+ `.trimStart();
8
+
9
+ function upsertPromoteScript(cwd, silent, logOk) {
10
+ const pkgPath = path.join(cwd, "package.json");
11
+ if (!fs.existsSync(pkgPath)) {
12
+ if (!silent) logOk("No package.json — add script manually: inferno:promote-draft");
13
+ return;
14
+ }
15
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
16
+ pkg.scripts = pkg.scripts || {};
17
+ if (!pkg.scripts["inferno:promote-draft"]) {
18
+ pkg.scripts["inferno:promote-draft"] = "node scripts/inferno-promote-draft.mjs";
19
+ fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
20
+ if (!silent) logOk("Updated package.json script: inferno:promote-draft");
21
+ }
22
+ }
23
+
24
+ /**
25
+ * inferno/CONTEXT.draft.md gitignore + promote script (shared by Cursor and VS Code installers).
26
+ * @param {object} opts
27
+ * @param {string} opts.cwd
28
+ * @param {string} opts.templatesRoot
29
+ * @param {boolean} opts.force
30
+ * @param {boolean} opts.silent
31
+ * @param {(msg: string) => void} [opts.logOk]
32
+ * @param {(msg: string) => void} [opts.logWarn]
33
+ */
34
+ export function installInfernoDraftTooling(opts) {
35
+ const { cwd, templatesRoot, force, silent } = opts;
36
+ const logOk = opts.logOk || (() => {});
37
+ const logWarn = opts.logWarn || (() => {});
38
+
39
+ function copyFile(src, dst) {
40
+ if (fs.existsSync(dst) && !force) {
41
+ if (!silent) logWarn("Skipped (exists): " + path.relative(cwd, dst));
42
+ return false;
43
+ }
44
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
45
+ fs.copyFileSync(src, dst);
46
+ if (!silent) logOk("Created: " + path.relative(cwd, dst));
47
+ return true;
48
+ }
49
+
50
+ const srcPromote = path.join(templatesRoot, "scripts", "inferno-promote-draft.mjs");
51
+ const dstPromote = path.join(cwd, "scripts", "inferno-promote-draft.mjs");
52
+ copyFile(srcPromote, dstPromote);
53
+
54
+ upsertPromoteScript(cwd, silent, logOk);
55
+
56
+ const gi = path.join(cwd, ".gitignore");
57
+ if (fs.existsSync(gi)) {
58
+ const cur = fs.readFileSync(gi, "utf8");
59
+ if (cur.includes("CONTEXT.draft.md")) {
60
+ if (!silent) logOk(".gitignore already mentions CONTEXT.draft.md");
61
+ } else {
62
+ fs.appendFileSync(gi, `\n${GITIGNORE_SNIPPET}\n`, "utf8");
63
+ if (!silent) logOk("Updated: " + path.relative(cwd, gi));
64
+ }
65
+ } else {
66
+ fs.writeFileSync(gi, `${GITIGNORE_SNIPPET}\n`, "utf8");
67
+ if (!silent) logOk("Created: " + path.relative(cwd, gi));
68
+ }
69
+ }
@@ -0,0 +1,42 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { installInfernoDraftTooling } from "./draftToolingInstall.mjs";
4
+
5
+ /**
6
+ * VS Code + GitHub Copilot agent hooks (Preview). See:
7
+ * https://code.visualstudio.com/docs/copilot/customization/hooks
8
+ *
9
+ * @param {object} opts
10
+ * @param {string} opts.cwd
11
+ * @param {string} opts.templatesRoot
12
+ * @param {boolean} opts.force
13
+ * @param {boolean} opts.silent
14
+ * @param {(msg: string) => void} [opts.logOk]
15
+ * @param {(msg: string) => void} [opts.logWarn]
16
+ */
17
+ export function installVsCodeCopilotHooksArtifacts(opts) {
18
+ const { cwd, templatesRoot, force, silent } = opts;
19
+ const logOk = opts.logOk || (() => {});
20
+ const logWarn = opts.logWarn || (() => {});
21
+
22
+ function copyFile(src, dst) {
23
+ if (fs.existsSync(dst) && !force) {
24
+ if (!silent) logWarn("Skipped (exists): " + path.relative(cwd, dst));
25
+ return false;
26
+ }
27
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
28
+ fs.copyFileSync(src, dst);
29
+ if (!silent) logOk("Created: " + path.relative(cwd, dst));
30
+ return true;
31
+ }
32
+
33
+ installInfernoDraftTooling({ cwd, templatesRoot, force, silent, logOk, logWarn });
34
+
35
+ const srcHooks = path.join(templatesRoot, "github-hooks", "infernoflow-drafts.json");
36
+ const dstHooks = path.join(cwd, ".github", "hooks", "infernoflow-drafts.json");
37
+ const srcHookScript = path.join(templatesRoot, "scripts", "inferno-vscode-copilot-hook.mjs");
38
+ const dstHookScript = path.join(cwd, "scripts", "inferno-vscode-copilot-hook.mjs");
39
+
40
+ copyFile(srcHooks, dstHooks);
41
+ copyFile(srcHookScript, dstHookScript);
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.10.8",
3
+ "version": "0.10.11",
4
4
  "description": "The forge for liquid code — keep capabilities, contracts, and docs in sync.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,12 +22,9 @@ jobs:
22
22
  - name: Install dependencies
23
23
  run: npm ci --ignore-scripts || npm install --ignore-scripts
24
24
 
25
- - name: Inferno one-command run
26
- run: npx infernoflow run "sync check" --json
25
+ - name: Inferno one-command run (headless prompt mode)
26
+ run: npx infernoflow run "sync check" --provider prompt --json
27
27
  env:
28
- INFERNO_LOCAL_PROVIDER: ollama
29
- INFERNO_LOCAL_ENDPOINT: http://127.0.0.1:11434/api/generate
30
- INFERNO_LOCAL_MODEL: llama3.1:8b
31
28
  BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
32
29
  HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
33
30
 
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Cursor hook: append agent output to inferno/CONTEXT.draft.md (gitignored).
4
+ * - Default stdin: afterAgentResponse → { text }
5
+ * - --agent-stop stdin: stop → { status, loop_count, ... }
6
+ * Never fail closed: errors go to stderr; stdout is {} for Cursor.
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+
11
+ /** Keep in sync with templates/scripts/inferno-promote-draft.mjs (split at first \\n---\\n). */
12
+ const DRAFT_HEADER = `# CONTEXT draft (gitignored)
13
+
14
+ Auto-captured by Cursor hooks (\`.cursor/hooks/inferno-session-draft.mjs\`). **Not product truth** — review, then run \`npm run inferno:promote-draft\` or \`infernoflow context\`.
15
+
16
+ ---
17
+ `;
18
+
19
+ const MAX_MESSAGE_CHARS = 120_000;
20
+ const MAX_FILE_BYTES = 280_000;
21
+
22
+ function projectRoot() {
23
+ return process.cwd();
24
+ }
25
+
26
+ function draftPath() {
27
+ return path.join(projectRoot(), "inferno", "CONTEXT.draft.md");
28
+ }
29
+
30
+ function ensureDraftFile(file) {
31
+ if (!fs.existsSync(file)) {
32
+ fs.mkdirSync(path.dirname(file), { recursive: true });
33
+ fs.writeFileSync(file, DRAFT_HEADER, "utf8");
34
+ }
35
+ }
36
+
37
+ function trimFile(file) {
38
+ const raw = fs.readFileSync(file, "utf8");
39
+ if (Buffer.byteLength(raw, "utf8") <= MAX_FILE_BYTES) return;
40
+ const keep = raw.slice(-Math.floor(MAX_FILE_BYTES * 0.85));
41
+ const idx = keep.indexOf("\n### ");
42
+ const body = idx === -1 ? keep : keep.slice(idx);
43
+ fs.writeFileSync(file, `${DRAFT_HEADER}\n_(older capture trimmed for size)_\n\n${body}`, "utf8");
44
+ }
45
+
46
+ function appendBlock(file, block) {
47
+ ensureDraftFile(file);
48
+ fs.appendFileSync(file, block, "utf8");
49
+ trimFile(file);
50
+ }
51
+
52
+ async function readStdin() {
53
+ const chunks = [];
54
+ for await (const c of process.stdin) chunks.push(c);
55
+ return Buffer.concat(chunks).toString("utf8");
56
+ }
57
+
58
+ function main() {
59
+ const agentStop = process.argv.includes("--agent-stop");
60
+
61
+ readStdin()
62
+ .then((raw) => {
63
+ let data = {};
64
+ try {
65
+ data = raw.trim() ? JSON.parse(raw) : {};
66
+ } catch (e) {
67
+ console.error("[inferno-session-draft] stdin JSON parse:", e.message);
68
+ console.log("{}");
69
+ process.exit(0);
70
+ return;
71
+ }
72
+
73
+ const file = draftPath();
74
+ if (agentStop) {
75
+ const status = data.status ?? "unknown";
76
+ const loop = data.loop_count ?? 0;
77
+ appendBlock(
78
+ file,
79
+ `\n### _agent stop_ (${new Date().toISOString()})\n\nstatus: \`${status}\` · loop_count: ${loop}\n\n---\n`
80
+ );
81
+ console.log("{}");
82
+ process.exit(0);
83
+ return;
84
+ }
85
+
86
+ const text = typeof data.text === "string" ? data.text : "";
87
+ if (!text.trim()) {
88
+ console.log("{}");
89
+ process.exit(0);
90
+ return;
91
+ }
92
+
93
+ const clipped =
94
+ text.length > MAX_MESSAGE_CHARS
95
+ ? `${text.slice(0, MAX_MESSAGE_CHARS)}\n\n_…trimmed (${text.length - MAX_MESSAGE_CHARS} chars omitted)_\n`
96
+ : text;
97
+
98
+ appendBlock(
99
+ file,
100
+ `\n### Assistant message (${new Date().toISOString()})\n\n${clipped}\n\n---\n`
101
+ );
102
+ console.log("{}");
103
+ process.exit(0);
104
+ })
105
+ .catch((e) => {
106
+ console.error("[inferno-session-draft]", e);
107
+ console.log("{}");
108
+ process.exit(0);
109
+ });
110
+ }
111
+
112
+ main();
@@ -0,0 +1,15 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "afterAgentResponse": [
5
+ {
6
+ "command": "node .cursor/hooks/inferno-session-draft.mjs"
7
+ }
8
+ ],
9
+ "stop": [
10
+ {
11
+ "command": "node .cursor/hooks/inferno-session-draft.mjs --agent-stop"
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "version": 1,
3
+ "hooks": {
4
+ "UserPromptSubmit": [
5
+ {
6
+ "type": "command",
7
+ "command": "node scripts/inferno-vscode-copilot-hook.mjs"
8
+ }
9
+ ],
10
+ "Stop": [
11
+ {
12
+ "type": "command",
13
+ "command": "node scripts/inferno-vscode-copilot-hook.mjs"
14
+ }
15
+ ]
16
+ }
17
+ }
@@ -14,13 +14,13 @@ if (!fs.existsSync(gitDir)) {
14
14
  fs.mkdirSync(hooksDir, { recursive: true });
15
15
 
16
16
  const preCommit = `#!/bin/sh
17
- echo "[inferno hooks] pre-commit: infernoflow run --dry-run"
18
- npx infernoflow run "sync check" --dry-run
17
+ echo "[inferno hooks] pre-commit: infernoflow run --provider auto --dry-run"
18
+ npx infernoflow run "sync check" --provider auto --dry-run
19
19
  `;
20
20
 
21
21
  const prePush = `#!/bin/sh
22
- echo "[inferno hooks] pre-push: infernoflow run --json"
23
- npx infernoflow run "sync check" --json
22
+ echo "[inferno hooks] pre-push: infernoflow run --provider auto --json"
23
+ npx infernoflow run "sync check" --provider auto --json
24
24
  `;
25
25
 
26
26
  const writeHook = (name, content) => {
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Merge inferno/CONTEXT.draft.md into inferno/CONTEXT.md under ## Decisions & notes,
4
+ * or clear the draft. Draft is gitignored; CONTEXT.md is the promoted source of truth.
5
+ */
6
+ import * as fs from "node:fs";
7
+ import * as path from "node:path";
8
+
9
+ const root = process.cwd();
10
+ const draftFile = path.join(root, "inferno", "CONTEXT.draft.md");
11
+ const contextFile = path.join(root, "inferno", "CONTEXT.md");
12
+
13
+ /** Keep in sync with .cursor/hooks/inferno-session-draft.mjs (getDraftBody splits at first \\n---\\n). */
14
+ const DRAFT_HEADER = `# CONTEXT draft (gitignored)
15
+
16
+ Auto-captured by IDE hooks (Cursor and/or VS Code + Copilot). **Not product truth** — review, then run \`npm run inferno:promote-draft\` or \`infernoflow context\`.
17
+
18
+ ---
19
+ `;
20
+
21
+ const DECISIONS_ANCHOR = "## Decisions & notes";
22
+ const PASTE_FOOTER_ANCHOR = "\n---\n_Paste this block at the start of any new AI session._";
23
+
24
+ function read(p) {
25
+ return fs.readFileSync(p, "utf8");
26
+ }
27
+
28
+ function write(p, s) {
29
+ fs.writeFileSync(p, s, "utf8");
30
+ }
31
+
32
+ function getDraftBody() {
33
+ if (!fs.existsSync(draftFile)) return "";
34
+ const full = read(draftFile);
35
+ const sep = full.indexOf("\n---\n");
36
+ if (sep === -1) return full.trim();
37
+ const after = full.slice(sep + "\n---\n".length).trim();
38
+ return after;
39
+ }
40
+
41
+ function clearDraft() {
42
+ fs.mkdirSync(path.dirname(draftFile), { recursive: true });
43
+ write(draftFile, DRAFT_HEADER);
44
+ console.log("Cleared inferno/CONTEXT.draft.md (header only).");
45
+ }
46
+
47
+ function appendNotes() {
48
+ if (!fs.existsSync(contextFile)) {
49
+ console.error("Missing inferno/CONTEXT.md");
50
+ process.exit(1);
51
+ }
52
+ const body = getDraftBody();
53
+ if (!body) {
54
+ console.error("Nothing to promote: inferno/CONTEXT.draft.md is empty after the header.");
55
+ process.exit(1);
56
+ }
57
+
58
+ const ctx = read(contextFile);
59
+ const i = ctx.indexOf(DECISIONS_ANCHOR);
60
+ if (i === -1) {
61
+ console.error(`Could not find "${DECISIONS_ANCHOR}" in inferno/CONTEXT.md`);
62
+ process.exit(1);
63
+ }
64
+ const j = ctx.indexOf(PASTE_FOOTER_ANCHOR, i);
65
+ if (j === -1) {
66
+ console.error("Could not find paste footer block in inferno/CONTEXT.md");
67
+ process.exit(1);
68
+ }
69
+
70
+ const before = ctx.slice(0, i + DECISIONS_ANCHOR.length);
71
+ const decisionsAndFooter = ctx.slice(i + DECISIONS_ANCHOR.length, j);
72
+ const after = ctx.slice(j);
73
+
74
+ let middle = decisionsAndFooter;
75
+ if (middle.includes("_No decisions recorded_")) {
76
+ middle = middle.replace("_No decisions recorded_", "").replace(/\n\n\n+/g, "\n\n");
77
+ }
78
+
79
+ const stamp = new Date().toISOString().slice(0, 19);
80
+ const indented = body.split("\n").map((line) => ` ${line}`).join("\n");
81
+ const block = `\n\n### Captured from agent draft (${stamp})\n\n${indented}\n`;
82
+
83
+ write(contextFile, `${before}${middle}${block}${after}`);
84
+ clearDraft();
85
+ console.log("Appended draft under ## Decisions & notes in inferno/CONTEXT.md and cleared the draft.");
86
+ console.log("Next: edit wording if needed, then run infernoflow check when contract/changelog should match.");
87
+ }
88
+
89
+ const args = process.argv.slice(2);
90
+ if (args.includes("--clear")) {
91
+ clearDraft();
92
+ process.exit(0);
93
+ }
94
+ if (args.includes("--append-notes")) {
95
+ appendNotes();
96
+ process.exit(0);
97
+ }
98
+
99
+ const body = getDraftBody();
100
+ if (!body) {
101
+ console.log("inferno/CONTEXT.draft.md has no captured content yet (after the header).");
102
+ console.log("Use the Agent chat; each assistant reply appends via the afterAgentResponse hook.");
103
+ process.exit(0);
104
+ }
105
+
106
+ console.log("--- inferno/CONTEXT.draft.md (excerpt, first 2000 chars) ---\n");
107
+ console.log(body.slice(0, 2000) + (body.length > 2000 ? "\n…" : ""));
108
+ console.log("\n---");
109
+ console.log("Promote into CONTEXT.md under Decisions:");
110
+ console.log(" npm run inferno:promote-draft -- --append-notes");
111
+ console.log("Or set working/intent via CLI:");
112
+ console.log(' npm exec -- infernoflow context --working "..." --intent "..."');
113
+ console.log("Clear draft without merging:");
114
+ console.log(" npm run inferno:promote-draft -- --clear");
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GitHub Copilot / VS Code agent hook (Preview): stdin JSON per
4
+ * https://code.visualstudio.com/docs/copilot/customization/hooks
5
+ *
6
+ * - UserPromptSubmit: appends the user's prompt to inferno/CONTEXT.draft.md
7
+ * - Stop: reads transcript_path (JSONL or session JSON), appends last assistant text if found
8
+ *
9
+ * Always prints {"continue":true} so the agent is never blocked. Errors → stderr only.
10
+ */
11
+ import * as fs from "node:fs";
12
+ import * as path from "node:path";
13
+
14
+ /** Keep in sync with inferno-promote-draft.mjs / inferno-session-draft.mjs */
15
+ const DRAFT_HEADER = `# CONTEXT draft (gitignored)
16
+
17
+ Auto-captured by VS Code / Copilot hooks. **Not product truth** — review, then run \`npm run inferno:promote-draft\` or \`infernoflow context\`.
18
+
19
+ ---
20
+ `;
21
+
22
+ const MAX_APPEND = 120_000;
23
+ const MAX_FILE_BYTES = 280_000;
24
+
25
+ function draftPath(root) {
26
+ return path.join(root, "inferno", "CONTEXT.draft.md");
27
+ }
28
+
29
+ function ensureDraft(file) {
30
+ if (!fs.existsSync(file)) {
31
+ fs.mkdirSync(path.dirname(file), { recursive: true });
32
+ fs.writeFileSync(file, DRAFT_HEADER, "utf8");
33
+ }
34
+ }
35
+
36
+ function trimFile(file) {
37
+ const raw = fs.readFileSync(file, "utf8");
38
+ if (Buffer.byteLength(raw, "utf8") <= MAX_FILE_BYTES) return;
39
+ const keep = raw.slice(-Math.floor(MAX_FILE_BYTES * 0.85));
40
+ const idx = keep.indexOf("\n### ");
41
+ const body = idx === -1 ? keep : keep.slice(idx);
42
+ fs.writeFileSync(file, `${DRAFT_HEADER}\n_(older capture trimmed for size)_\n\n${body}`, "utf8");
43
+ }
44
+
45
+ function append(root, block) {
46
+ const file = draftPath(root);
47
+ ensureDraft(file);
48
+ fs.appendFileSync(file, block, "utf8");
49
+ trimFile(file);
50
+ }
51
+
52
+ function readStdinSync() {
53
+ /** Prefer readSync: on some Windows shells, readFileSync(0) returns empty for piped hook stdin. */
54
+ const buf = Buffer.alloc(16 * 1024 * 1024);
55
+ let n = 0;
56
+ try {
57
+ n = fs.readSync(0, buf, 0, buf.length, null);
58
+ } catch {
59
+ return "";
60
+ }
61
+ return buf.slice(0, n).toString("utf8");
62
+ }
63
+
64
+ function flattenResponse(resp) {
65
+ if (!resp) return "";
66
+ if (typeof resp === "string") return resp.slice(0, MAX_APPEND);
67
+ if (typeof resp.markdown === "string") return resp.markdown;
68
+ if (typeof resp.text === "string") return resp.text;
69
+ if (Array.isArray(resp.parts)) {
70
+ const bits = resp.parts
71
+ .map((p) => (typeof p === "string" ? p : p?.text || p?.content || p?.value || ""))
72
+ .filter(Boolean);
73
+ if (bits.length) return bits.join("\n").slice(0, MAX_APPEND);
74
+ }
75
+ if (resp.message && typeof resp.message.text === "string") return resp.message.text;
76
+ return "";
77
+ }
78
+
79
+ function lastAssistantFromSessionJson(data) {
80
+ const reqs = data.requests;
81
+ if (!Array.isArray(reqs)) return "";
82
+ for (let i = reqs.length - 1; i >= 0; i--) {
83
+ const t = flattenResponse(reqs[i]?.response);
84
+ if (t && t.trim()) return t.slice(0, MAX_APPEND);
85
+ }
86
+ return "";
87
+ }
88
+
89
+ function extractFromJsonlLine(obj) {
90
+ if (!obj || typeof obj !== "object") return "";
91
+ const a = obj.assistant;
92
+ if (a && typeof a.message === "string" && a.message.trim()) return a.message.slice(0, MAX_APPEND);
93
+ if (a && typeof a.text === "string" && a.text.trim()) return a.text.slice(0, MAX_APPEND);
94
+ if ((obj.role === "assistant" || obj.type === "assistant") && typeof obj.message === "string")
95
+ return obj.message.slice(0, MAX_APPEND);
96
+ if ((obj.role === "assistant" || obj.type === "assistant") && typeof obj.content === "string")
97
+ return obj.content.slice(0, MAX_APPEND);
98
+ if (obj.assistantMessage && typeof obj.assistantMessage === "string")
99
+ return obj.assistantMessage.slice(0, MAX_APPEND);
100
+ return "";
101
+ }
102
+
103
+ function lastAssistantFromTranscriptFile(transcriptPath) {
104
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return "";
105
+ const raw = fs.readFileSync(transcriptPath, "utf8").trim();
106
+ if (!raw) return "";
107
+
108
+ if (raw.startsWith("{")) {
109
+ try {
110
+ const data = JSON.parse(raw);
111
+ const fromReq = lastAssistantFromSessionJson(data);
112
+ if (fromReq) return fromReq;
113
+ } catch {
114
+ /* fall through */
115
+ }
116
+ }
117
+
118
+ let last = "";
119
+ for (const line of raw.split("\n")) {
120
+ const t = line.trim();
121
+ if (!t || t[0] !== "{") continue;
122
+ try {
123
+ const v = extractFromJsonlLine(JSON.parse(t));
124
+ if (v) last = v;
125
+ } catch {
126
+ /* skip line */
127
+ }
128
+ }
129
+ return last;
130
+ }
131
+
132
+ function main() {
133
+ let data = {};
134
+ try {
135
+ const s = readStdinSync().trim();
136
+ if (s) data = JSON.parse(s);
137
+ } catch (e) {
138
+ console.error("[inferno-vscode-copilot-hook] stdin JSON:", e.message);
139
+ console.log(JSON.stringify({ continue: true }));
140
+ process.exit(0);
141
+ return;
142
+ }
143
+
144
+ const root = data.cwd || process.cwd();
145
+ const hook = String(data.hookEventName || data.hook_event_name || "")
146
+ .replace(/\s+/g, "")
147
+ .toLowerCase();
148
+
149
+ try {
150
+ if (hook === "userpromptsubmit") {
151
+ const prompt = data.prompt || data.Prompt || "";
152
+ if (typeof prompt === "string" && prompt.trim()) {
153
+ append(
154
+ root,
155
+ `\n### User prompt (${new Date().toISOString()})\n\n${prompt.slice(0, MAX_APPEND)}\n\n---\n`
156
+ );
157
+ }
158
+ } else if (hook === "stop") {
159
+ const tp = data.transcript_path || data.transcriptPath;
160
+ const assistant = lastAssistantFromTranscriptFile(tp);
161
+ const stopActive = data.stop_hook_active ?? data.stopHookActive;
162
+ if (assistant) {
163
+ append(
164
+ root,
165
+ `\n### Assistant (from transcript) (${new Date().toISOString()})\n\n${assistant}\n\n---\n`
166
+ );
167
+ } else {
168
+ append(
169
+ root,
170
+ `\n### _Copilot Stop_ (${new Date().toISOString()})\n\nstop_hook_active: ${Boolean(stopActive)}${
171
+ tp ? ` · transcript: ${tp}` : " · (no transcript_path or empty parse)"
172
+ }\n\n---\n`
173
+ );
174
+ }
175
+ }
176
+ } catch (e) {
177
+ console.error("[inferno-vscode-copilot-hook]", e);
178
+ }
179
+
180
+ console.log(JSON.stringify({ continue: true }));
181
+ process.exit(0);
182
+ }
183
+
184
+ main();