infernoflow 0.42.1 → 0.42.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog — infernoflow
2
2
 
3
+ ## 0.42.4 — 2026-05-05
4
+
5
+ ### Added
6
+ - **Cross-platform CI matrix** — `.github/workflows/ci.yml` now runs the smoke suite on **ubuntu-latest + windows-latest + macos-latest** across Node 18/20/22 (7 cells total). Catches Windows path / line-ending / shell-quoting bugs at PR time, not after a release.
7
+ - **Production audit job in CI** — `npm audit --omit=dev --audit-level=high` runs on every push so any introduced vulnerability fails the build.
8
+ - **VS Code extension shipped to v0.7.0** — `vscode-extension/package.json` modernised: leads with "Persistent memory for AI coding sessions", expanded keywords (ai-memory, amp, copilot/cursor/claude/windsurf), AI Marketplace category, gallery banner. Activates on the AMP layout (`.ai-memory/sessions.jsonl`) AND on the legacy `inferno/` layout.
9
+ - **`vscode-extension/PUBLISH.md`** — one-time setup walkthrough (Azure DevOps PAT + `vsce login`) and recurring `npx vsce publish` workflow for shipping the extension to the VS Code Marketplace.
10
+ - **`vscode-extension/CHANGELOG.md`** — Marketplace renders it on the listing page.
11
+
12
+ ### Internal
13
+ - Marketplace publish itself is a manual step (needs the maintainer's Azure DevOps PAT). All the prep — version, README, CHANGELOG, manifest fields, gallery banner — is in place; `npx vsce publish` from `vscode-extension/` ships it.
14
+
15
+ ## 0.42.3 — 2026-05-05
16
+
17
+ ### Added
18
+ - **`infernoflow amp` subsystem** — first-class verbs (status / migrate / validate / version) for the AI Memory Protocol. Already surfaced via the `amp` namespace dispatcher.
19
+ - **AMP MCP tool aliases** — `amp_read`, `amp_write`, `amp_handoff`, `amp_search`, `amp_health` exposed alongside the existing `infernoflow_*` tools in the bundled MCP server.
20
+ - **`switch` output redesign** — handoff is now screenshot-worthy:
21
+ - `## ⚠️ STOP — Read These Before Doing Anything (N gotchas)` banner
22
+ - Numbered lists for gotchas, decisions, attempts (not bullets)
23
+ - File paths shown inline next to gotchas (`→ File: src/api.js`)
24
+ - Session health score in header (`Health: A (90/100)`)
25
+ - Dropped redundant "Open threads" and "Recent session log" sections
26
+ - **Trust badges in README** — `dependencies-0` and `npm-audit-0-vulnerabilities`. Backed by `npm audit` returning clean.
27
+ - **Demo GIF recording guide** at `docs/DEMO_GIF.md` — 30-second shot list with timings.
28
+
29
+ ### Fixed
30
+ - `switch` health-score computation referenced an undefined `notes` filter; corrected so it builds against the actual gotcha/decision/attempt counts (max 90 instead of 100, since notes don't materialise here).
31
+ - Smoke test's gotcha-section assertion updated to match the new "STOP" header.
32
+
33
+ ## 0.42.2 — 2026-05-03
34
+
35
+ ### Added
36
+ - **AMP MCP tool aliases** — the bundled MCP server (`templates/cursor/inferno-mcp-server.mjs`) now exposes the 5 vendor-neutral AMP tools alongside the existing `infernoflow_*` tools: `amp_read`, `amp_write`, `amp_handoff`, `amp_search`, `amp_health`. AMP-only clients (any tool that follows AMP MCP §7.3) can call infernoflow without knowing the `infernoflow_` prefix. Backward compat preserved — all 9 `infernoflow_*` tools still work.
37
+ - **README MCP table updated** — lists all 14 tools, marks the AMP-spec aliases distinctly with a pointer to the protocol spec.
38
+
39
+ ### Internal
40
+ - New install of `infernoflow install-cursor-hooks` ships the 14-tool MCP server. Existing installs continue to work; re-run `install-cursor-hooks` to pick up the AMP aliases.
41
+
42
+ ## Unreleased
43
+
44
+ > Changes since v0.42.1
45
+
46
+ ### Changed
47
+ - v0.42.1 — `infernoflow amp` subsystem; protocol package renamed @amp/core → ai-memory-protocol for npm publish
48
+
49
+
50
+
3
51
  ## 0.42.1 — 2026-05-03
4
52
 
5
53
  ### Added
@@ -195,12 +243,4 @@ Then: `infernoflow logout && infernoflow login --browser`. If anything misbehave
195
243
  ## 0.10.20 — 2026-04-21
196
244
 
197
245
  ### Added
198
- - Release 0.10.20
199
-
200
-
201
- ## 0.10.19 — 2026-04-21
202
-
203
- ## 0.10.12 — 2026-04-12
204
-
205
- ### Added
206
- - `infernoflow install-cursor-hooks` — Cursor Agent hooks append assistant replies to `inferno/CON
246
+ - Release 0.
package/README.md CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  [![npm version](https://img.shields.io/npm/v/infernoflow.svg?color=orange)](https://www.npmjs.com/package/infernoflow)
8
8
  [![npm downloads](https://img.shields.io/npm/dw/infernoflow.svg?color=orange)](https://www.npmjs.com/package/infernoflow)
9
+ [![zero dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)](./package.json)
10
+ [![npm audit](https://img.shields.io/badge/npm%20audit-0%20vulnerabilities-brightgreen)](https://docs.npmjs.com/cli/v10/commands/npm-audit)
9
11
 
10
12
  ## The 60-second pitch
11
13
 
@@ -66,6 +68,12 @@ Each entry on disk is AMP wire format:
66
68
 
67
69
  The full spec is in [docs/protocol/PROTOCOL.md](docs/protocol/PROTOCOL.md). Any tool that can parse JSONL can read your memory — that's the whole point. infernoflow is currently the **AMP Full** reference implementation: read + write + handoff + injection across CLAUDE.md / .cursorrules / copilot-instructions.md.
68
70
 
71
+ Building your own AMP-compatible tool? Use the reference TS library:
72
+
73
+ ```bash
74
+ npm install infernoflow-amp
75
+ ```
76
+
69
77
  If you have a project on the legacy `inferno/sessions.jsonl` layout, migrate with one command:
70
78
 
71
79
  ```bash
@@ -107,6 +115,13 @@ After install-cursor-hooks, your AI agent can call infernoflow directly in chat:
107
115
  | `infernoflow_review` | Pre-merge capability drift check on the current branch |
108
116
  | `infernoflow_git_drift` | Detect capabilities affected by recent commits |
109
117
  | `infernoflow_scan_ui` | Detect UI / design-token changes vs contract |
118
+ | `amp_read` | **AMP-spec** alias — read entries with optional filters |
119
+ | `amp_write` | **AMP-spec** alias — log a new entry |
120
+ | `amp_handoff` | **AMP-spec** alias — generate the handoff document |
121
+ | `amp_search` | **AMP-spec** alias — search entries by keyword |
122
+ | `amp_health` | **AMP-spec** alias — session health score |
123
+
124
+ The `amp_*` tools are vendor-neutral aliases following the [AMP MCP spec §7.3](docs/protocol/PROTOCOL.md#73-mcp-tool-interface). Any AMP-Full client only needs to know these names — the `infernoflow_*` set stays for backward compat.
110
125
 
111
126
  ## Cloud sync (optional)
112
127
 
@@ -1,11 +1,11 @@
1
- import*as E from"node:fs";import*as g from"node:path";import"node:os";import{execSync as w}from"node:child_process";import{bold as W,cyan as S,gray as et,green as ot,yellow as B,red as it}from"../ui/output.mjs";import{ampPaths as rt,readEntries as ct,appendEntry as lt}from"../amp/io.mjs";const I="inferno";function V(){return rt(process.cwd())}const at=g.join(I,"HANDOFF.md"),wt=g.join(I,"sessions.jsonl"),J=g.join(I,"context-state.json"),z=g.join(I,"contract.json"),K=g.join(I,"theme.json"),X=g.join(I,"adoption_profile.json");function f(o){try{return JSON.parse(E.readFileSync(o,"utf8"))}catch{return null}}function pt(o){try{return E.readFileSync(o,"utf8")}catch{return null}}function F(o){return o?new Date(o).toLocaleString("en-GB",{day:"2-digit",month:"short",hour:"2-digit",minute:"2-digit"}):"unknown"}function q(o){if(o<0)return"unknown";const i=Math.floor(o/36e5),l=Math.floor(o%36e5/6e4);return i>0?`${i}h ${l}m`:`${l}m`}function Q(){return ct(process.cwd())}function Y(o,i,l){if(l)return new Date(0);if(i){const n=i.match(/^(\d+)h$/i),c=i.match(/^(\d+)d$/i);if(n)return new Date(Date.now()-parseInt(n[1])*36e5);if(c)return new Date(Date.now()-parseInt(c[1])*864e5);const s=new Date(i);if(!isNaN(s))return s}for(let n=o.length-1;n>=0;n--)if(o[n].type==="handoff"){const c=new Date(o[n].ts||0),s=new Date(Date.now()-864e5);return c>s?c:s}return new Date(Date.now()-864e5)}function ft(o){try{const i=process.platform;if(i==="win32")w("clip",{input:o});else if(i==="darwin")w("pbcopy",{input:o});else try{w("xclip -selection clipboard",{input:o})}catch{w("xsel --clipboard --input",{input:o})}return!0}catch{return!1}}function Z(){if(process.env.CURSOR_SESSION)return"Cursor";if(process.env.COPILOT_SESSION)return"GitHub Copilot";if(process.env.CLAUDE_CODE_SESSION)return"Claude Code";if(process.env.WINDSURF_SESSION)return"Windsurf";if(process.env.TERM_PROGRAM==="vscode")return"VS Code";const o=f(X);return o?.ide?o.ide:null}function ut(){try{const o=w("git diff --stat HEAD 2>/dev/null || git diff --cached --stat 2>/dev/null",{encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim();return o||w("git log --stat -1 --pretty= 2>/dev/null",{encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim()||null}catch{return null}}function nt(o){try{const i=o&&o.getTime()>0?`--after="${o.toISOString()}"`:"-10",l=w(`git log ${i} --name-only --pretty=format: 2>/dev/null`,{encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim();if(!l)return[];const n={};for(const c of l.split(`
2
- `)){const s=c.trim();s&&(n[s]=(n[s]||0)+1)}return Object.entries(n).sort((c,s)=>s[1]-c[1]).slice(0,5).map(([c,s])=>({file:c,edits:s}))}catch{return[]}}function tt(o){try{const i=o?`--after="${o.toISOString()}"`:"-5",l=w(`git log ${i} --pretty=format:"%h %s" 2>/dev/null`,{encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim();return l?l.split(`
3
- `).filter(Boolean):[]}catch{return[]}}function st(o){const i=[],l=o.filter(n=>n.type==="attempt"&&(n.result==="failed"||n.result==="partial"||!n.result));for(const n of l)o.find(s=>s.type==="attempt"&&s.result==="worked"&&new Date(s.ts)>new Date(n.ts)&&s.summary.toLowerCase().includes(n.summary.split(" ")[0].toLowerCase()))||i.push({text:n.summary,ts:n.ts,kind:"unresolved-attempt"});for(const n of o)/\b(TODO|WIP|FIXME|BLOCKED|pending)\b/i.test(n.summary)&&(i.find(c=>c.text===n.summary)||i.push({text:n.summary,ts:n.ts,kind:"flagged"}));return i.slice(0,8)}function dt(o,i,l){const n=f(J)||{},c=f(z)||{},s=f(K),b=f(X),$=Q(),a=Y($,i,l),k=$.filter(e=>new Date(e.ts||0)>a),H=$.slice(-5),D=new Date,u=D.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"}),_=c.policyId||g.basename(process.cwd()),j=c.policyVersion||"?",O=(c.capabilities||[]).slice(0,20),x=Z(),R=a.getTime()>0?D-a:-1,N=q(R),A=a.getTime()>0?a.getTime().toString(16).slice(-6).toUpperCase():"ALL",T=tt(a.getTime()>0?a:null),C=ut(),G=nt(a.getTime()>0?a:null),m=k.length>0?k:H,r=m.filter(e=>e.type==="gotcha"),h=m.filter(e=>e.type==="decision"),M=m.filter(e=>e.type==="attempt").filter(e=>e.result==="failed"||e.result==="partial"),P=m.filter(e=>e.type==="preference"),L=m.slice(-8),d=st(m),U=a.getTime()===0?"all time":a.toLocaleString("en-GB",{day:"2-digit",month:"short",hour:"2-digit",minute:"2-digit"}),y=["sessions.jsonl"];(n.working||n.intent)&&y.push("context-state.json"),s&&y.push("theme.json"),c.capabilities?.length&&y.push("contract.json"),b&&y.push("adoption_profile.json"),T.length&&y.push("git log");const t=[`# \u{1F525} infernoflow Handoff \u2014 ${_}`,`> Generated: ${u}${o?` | Handing off to: **${o}**`:""}`,`> Session: **#${A}** \xB7 ${N} \xB7 **${k.length} entries** since ${U}`,`> Sources: ${y.join(" \xB7 ")}${x?` \xB7 IDE: ${x}`:""}`,"","---","","## Pick up here",""];if(n.working||n.intent?(n.working&&t.push(`**Working on:** ${n.working} _(${F(n.workingUpdated)})_`),n.intent&&t.push(`**Intent:** ${n.intent} _(${F(n.intentUpdated)})_`),t.push("")):t.push('_No working state set. Run: `infernoflow context --working "..."` to set it._',""),d.length){t.push("## \u{1F513} Open threads \u2014 not yet resolved","");for(const e of d){const p=e.kind==="flagged"?"[flagged]":"[unresolved]";t.push(`- ${p} ${e.text} _(${F(e.ts)})_`)}t.push("")}if(r.length){t.push("## \u26A0 Gotchas \u2014 read these first","");for(const e of r)t.push(`- ${e.summary} _(${F(e.ts)})_`);t.push("")}if(h.length){t.push("## Decisions made","");for(const e of h){const p=e.result?` \u2192 **${e.result}**`:"";t.push(`- ${e.summary}${p} _(${F(e.ts)})_`)}t.push("")}if(M.length){t.push("## \u2717 Already tried \u2014 don't repeat","");for(const e of M)t.push(`- ${e.summary} _(${F(e.ts)})_`);t.push("")}if(G.length){t.push("## \u{1F4C1} Hot Files This Session","");for(const{file:e,edits:p}of G)t.push(`- \`${e}\` \u2014 ${p} edit${p!==1?"s":""}`);t.push("")}if(P.length){t.push("## Developer preferences","");for(const e of P)t.push(`- ${e.summary}`);t.push("")}if(T.length||C){if(t.push("## Git activity this session",""),T.length){t.push("**Commits:**");for(const e of T)t.push(`- \`${e}\``);t.push("")}C&&(t.push("**Uncommitted changes:**"),t.push("```"),t.push(C.split(`
1
+ import*as b from"node:fs";import*as m from"node:path";import"node:os";import{execSync as S}from"node:child_process";import{bold as V,cyan as $,gray as it,green as rt,yellow as J,red as pt}from"../ui/output.mjs";import{ampPaths as ut,readEntries as dt,appendEntry as ht}from"../amp/io.mjs";const T="inferno";function z(){return ut(process.cwd())}const gt=m.join(T,"HANDOFF.md"),Ft=m.join(T,"sessions.jsonl"),K=m.join(T,"context-state.json"),X=m.join(T,"contract.json"),q=m.join(T,"theme.json"),Q=m.join(T,"adoption_profile.json");function u(t){try{return JSON.parse(b.readFileSync(t,"utf8"))}catch{return null}}function mt(t){try{return b.readFileSync(t,"utf8")}catch{return null}}function Y(t){return t?new Date(t).toLocaleString("en-GB",{day:"2-digit",month:"short",hour:"2-digit",minute:"2-digit"}):"unknown"}function Z(t){if(t<0)return"unknown";const i=Math.floor(t/36e5),l=Math.floor(t%36e5/6e4);return i>0?`${i}h ${l}m`:`${l}m`}function tt(){return dt(process.cwd())}function et(t,i,l){if(l)return new Date(0);if(i){const o=i.match(/^(\d+)h$/i),c=i.match(/^(\d+)d$/i);if(o)return new Date(Date.now()-parseInt(o[1])*36e5);if(c)return new Date(Date.now()-parseInt(c[1])*864e5);const s=new Date(i);if(!isNaN(s))return s}for(let o=t.length-1;o>=0;o--)if(t[o].type==="handoff"){const c=new Date(t[o].ts||0),s=new Date(Date.now()-864e5);return c>s?c:s}return new Date(Date.now()-864e5)}function yt(t){try{const i=process.platform;if(i==="win32")S("clip",{input:t});else if(i==="darwin")S("pbcopy",{input:t});else try{S("xclip -selection clipboard",{input:t})}catch{S("xsel --clipboard --input",{input:t})}return!0}catch{return!1}}function ot(){if(process.env.CURSOR_SESSION)return"Cursor";if(process.env.COPILOT_SESSION)return"GitHub Copilot";if(process.env.CLAUDE_CODE_SESSION)return"Claude Code";if(process.env.WINDSURF_SESSION)return"Windsurf";if(process.env.TERM_PROGRAM==="vscode")return"VS Code";const t=u(Q);return t?.ide?t.ide:null}function wt(){try{const t=S("git diff --stat HEAD 2>/dev/null || git diff --cached --stat 2>/dev/null",{encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim();return t||S("git log --stat -1 --pretty= 2>/dev/null",{encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim()||null}catch{return null}}function ct(t){try{const i=t&&t.getTime()>0?`--after="${t.toISOString()}"`:"-10",l=S(`git log ${i} --name-only --pretty=format: 2>/dev/null`,{encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim();if(!l)return[];const o={};for(const c of l.split(`
2
+ `)){const s=c.trim();s&&(o[s]=(o[s]||0)+1)}return Object.entries(o).sort((c,s)=>s[1]-c[1]).slice(0,5).map(([c,s])=>({file:c,edits:s}))}catch{return[]}}function nt(t){try{const i=t?`--after="${t.toISOString()}"`:"-5",l=S(`git log ${i} --pretty=format:"%h %s" 2>/dev/null`,{encoding:"utf8",stdio:["pipe","pipe","pipe"]}).trim();return l?l.split(`
3
+ `).filter(Boolean):[]}catch{return[]}}function lt(t){const i=[],l=t.filter(o=>o.type==="attempt"&&(o.result==="failed"||o.result==="partial"||!o.result));for(const o of l)t.find(s=>s.type==="attempt"&&s.result==="worked"&&new Date(s.ts)>new Date(o.ts)&&s.summary.toLowerCase().includes(o.summary.split(" ")[0].toLowerCase()))||i.push({text:o.summary,ts:o.ts,kind:"unresolved-attempt"});for(const o of t)/\b(TODO|WIP|FIXME|BLOCKED|pending)\b/i.test(o.summary)&&(i.find(c=>c.text===o.summary)||i.push({text:o.summary,ts:o.ts,kind:"flagged"}));return i.slice(0,8)}function St(t,i,l){const o=u(K)||{},c=u(X)||{},s=u(q),A=u(Q),D=tt(),a=et(D,i,l),N=D.filter(n=>new Date(n.ts||0)>a),H=D.slice(-5),I=new Date,d=I.toLocaleString("en-GB",{day:"2-digit",month:"short",year:"numeric",hour:"2-digit",minute:"2-digit"}),j=c.policyId||m.basename(process.cwd()),E=c.policyVersion||"?",O=(c.capabilities||[]).slice(0,20),x=ot(),M=a.getTime()>0?I-a:-1,k=Z(M),L=a.getTime()>0?a.getTime().toString(16).slice(-6).toUpperCase():"ALL",C=nt(a.getTime()>0?a:null),_=wt(),R=ct(a.getTime()>0?a:null),y=N.length>0?N:H,r=y.filter(n=>n.type==="gotcha"),h=y.filter(n=>n.type==="decision"),v=y.filter(n=>n.type==="attempt").filter(n=>n.result==="failed"||n.result==="partial"),G=y.filter(n=>n.type==="preference"),W=y.slice(-8),g=lt(y),st=a.getTime()===0?"all time":a.toLocaleString("en-GB",{day:"2-digit",month:"short",hour:"2-digit",minute:"2-digit"}),w=["sessions.jsonl"];(o.working||o.intent)&&w.push("context-state.json"),s&&w.push("theme.json"),c.capabilities?.length&&w.push("contract.json"),A&&w.push("adoption_profile.json"),C.length&&w.push("git log");const B=r.length,P=h.length,at=v.length;let F=Math.min(B*20,40)+Math.min(P*15,30)+Math.min(at*15,20);F=Math.min(F,100);const ft=F>=80?"A":F>=60?"B":F>=40?"C":F>=20?"D":"F",e=[`# \u{1F525} infernoflow Handoff \u2014 ${j}`,`> Generated: ${d}${t?` | Handing off to: **${t}**`:""}`,`> Session: **#${L}** \xB7 ${k} \xB7 **${N.length} entries** \xB7 Health: **${ft}** (${F}/100)`,`> Sources: ${w.join(" \xB7 ")}${x?` \xB7 IDE: ${x}`:""}`,"","---",""];if((o.working||o.intent)&&(e.push("## \u{1F3AF} Working on",""),o.working&&e.push(`**${o.working}** _(${Y(o.workingUpdated)})_`),o.intent&&e.push(`Intent: ${o.intent} _(${Y(o.intentUpdated)})_`),e.push("")),r.length&&(e.push(`## \u26A0\uFE0F STOP \u2014 Read These Before Doing Anything (${r.length} gotcha${r.length===1?"":"s"})`,""),r.forEach((n,p)=>{e.push(`${p+1}. **${n.summary}**`);const f=n.file||n.source;if(f&&/[\\/.]/.test(f)){const U=n.line?`${f}:${n.line}`:f;e.push(` \u2192 File: \`${U}\``)}}),e.push("")),h.length&&(e.push("## \u2713 Decisions In Effect \u2014 Follow These",""),h.forEach((n,p)=>{const f=n.result?` \u2192 **${n.result}**`:"";e.push(`${p+1}. ${n.summary}${f}`)}),e.push("")),v.length&&(e.push("## \u274C Already Tried \u2014 Don't Repeat",""),v.forEach((n,p)=>{const f=n.file||n.source,U=f&&/[\\/.]/.test(f)?` (\`${f}\`)`:"";e.push(`${p+1}. ${n.summary}${U} _(${Y(n.ts)})_`)}),e.push("")),R.length){e.push("## \u{1F4C1} Hot Files This Session","");for(const{file:n,edits:p}of R)e.push(`- \`${n}\` \u2014 ${p} edit${p!==1?"s":""}`);e.push("")}if(G.length){e.push("## Developer preferences","");for(const n of G)e.push(`- ${n.summary}`);e.push("")}if(C.length||_){if(e.push("## Git activity this session",""),C.length){e.push("**Commits:**");for(const n of C)e.push(`- \`${n}\``);e.push("")}_&&(e.push("**Uncommitted changes:**"),e.push("```"),e.push(_.split(`
4
4
  `).slice(0,15).join(`
5
- `)),t.push("```"),t.push(""))}if(s){if(t.push("## Design system",""),s.fonts?.primary&&t.push(`- **Font:** ${s.fonts.primary}${s.fonts.mono?` \xB7 mono: ${s.fonts.mono}`:""}`),s.colors?.mode&&t.push(`- **Mode:** ${s.colors.mode}`),s.colors?.palette){const e=Object.entries(s.colors.palette).map(([p,v])=>`${p}=${v}`).join(" ");t.push(`- **Palette:** ${e}`)}if(s.cssVars&&Object.keys(s.cssVars).length){const e=Object.entries(s.cssVars).slice(0,6).map(([p,v])=>`${p}: ${v}`).join(" | ");t.push(`- **CSS vars:** ${e}`)}s.framework&&t.push(`- **Framework:** ${s.framework}`),t.push("","> \u26A0 Always match these exactly. Do not introduce new colors or fonts.","")}if(O.length&&(t.push("## Capability contract",""),t.push(`Project: **${_}** v${j}`),t.push(`Capabilities: ${O.join(", ")}`),t.push("")),L.length){t.push("## Recent session log","");for(const e of L){const p=e.result?` [${e.result}]`:"",v=e.source?` {${e.source}}`:"";t.push(`- **${e.type}**${p}${v}: ${e.summary} _(${F(e.ts)})_`)}t.push("")}return t.push("---"),t.push(`_Session #${A} \xB7 ${N} \xB7 Generated by infernoflow._`),t.join(`
6
- `)}async function St(o){const i=r=>o.includes(r),l=r=>{const h=o.indexOf(r);return h!==-1&&o[h+1]?o[h+1]:null},n=i("--show")||i("-s"),c=i("--copy")||i("-c"),s=i("--json"),b=i("--all"),$=l("--since"),a=l("--to")||o.find(r=>!r.startsWith("-")&&!["switch"].includes(r))||null;console.log(`
7
- `+W("\u{1F525} infernoflow \u2014 switch")),console.log(" "+"\u2500".repeat(50)+`
8
- `);const k=g.join(process.cwd(),".ai-memory");if(!E.existsSync(I)&&!E.existsSync(k)&&(console.error(it(` \u2718 not initialized \u2014 run: infernoflow init
9
- `)),process.exit(1)),n){const r=pt(at);if(!r){console.log(B(` \u26A0 No HANDOFF.md yet \u2014 run: infernoflow switch
10
- `));return}console.log(r);return}const H=dt(a,$,b);if(s){const r=f(J)||{},h=f(z)||{},M=f(K),P=f(X),L=Q(),d=Y(L,$,b),U=L.filter(e=>new Date(e.ts||0)>d),y=tt(d.getTime()>0?d:null),t=Z();console.log(JSON.stringify({state:r,contract:{policyId:h.policyId,policyVersion:h.policyVersion,capabilities:h.capabilities},theme:M,adoption:P,sessions:U,commits:y,ide:t,sessionStart:d.toISOString(),sessionId:d.getTime()>0?d.getTime().toString(16).slice(-6).toUpperCase():"ALL",sessionDuration:q(d.getTime()>0?Date.now()-d.getTime():-1),generatedAt:new Date().toISOString()},null,2));return}E.writeFileSync(V().handoff,H,"utf8"),console.log(ot(" \u2714 Written \u2192 "+g.relative(process.cwd(),V().handoff)+`
11
- `));const D=Q(),u=Y(D,$,b),_=D.filter(r=>new Date(r.ts||0)>u),j=f(J)||{},O=f(K),x=f(z)||{},R=tt(u.getTime()>0?u:null),N=nt(u.getTime()>0?u:null),A=Z(),T=_.length>0?_:D.slice(-5),C=st(T),G=q(u.getTime()>0?Date.now()-u.getTime():-1),m=u.getTime()>0?u.getTime().toString(16).slice(-6).toUpperCase():"ALL";if(console.log(" "+W("Handoff ready")),console.log(" "+"\u2500".repeat(50)),console.log(" "+et("Session #"+m+" \xB7 "+G)),j.working&&console.log(" Working on "+S(j.working)),j.intent&&console.log(" Intent "+S(j.intent)),console.log(" Memory "+_.length+" entries this session (total: "+D.length+")"),C.length&&console.log(" Open threads "+B(C.length+" unresolved")),R.length&&console.log(" Git commits "+R.length+" this session"),N.length&&console.log(" Hot files "+N.map(r=>S(r.file)).join(", ")),console.log(" Capabilities "+(x.capabilities||[]).length+" registered"),O?.fonts?.primary&&console.log(" Font "+O.fonts.primary),O?.colors?.mode&&console.log(" Color mode "+O.colors.mode),A&&console.log(" IDE "+A),a&&console.log(" Handing off \u2192 "+S(a)),console.log(),c){const r=ft(H);console.log(r?ot(" \u2714 Copied to clipboard \u2014 paste at the start of your next AI session"):B(" \u26A0 Clipboard failed \u2014 open inferno/HANDOFF.md manually"))}else console.log(" "+W("Ready to use:")),console.log(" "+S("1.")+" Open "+S("inferno/HANDOFF.md")),console.log(" "+S("2.")+" Copy all"),console.log(" "+S("3.")+" Paste at the start of your next AI session"),console.log(" "+et(" tip: use --copy to skip steps 1-2 automatically"));if(console.log(),E.existsSync(V().sessions)){const r={ts:new Date().toISOString(),agent:"infernoflow",type:"handoff",summary:a?`Handed off to ${a}`:"Handoff generated"};lt(process.cwd(),r)}}export{St as switchCommand};
5
+ `)),e.push("```"),e.push(""))}if(s){if(e.push("## Design system",""),s.fonts?.primary&&e.push(`- **Font:** ${s.fonts.primary}${s.fonts.mono?` \xB7 mono: ${s.fonts.mono}`:""}`),s.colors?.mode&&e.push(`- **Mode:** ${s.colors.mode}`),s.colors?.palette){const n=Object.entries(s.colors.palette).map(([p,f])=>`${p}=${f}`).join(" ");e.push(`- **Palette:** ${n}`)}if(s.cssVars&&Object.keys(s.cssVars).length){const n=Object.entries(s.cssVars).slice(0,6).map(([p,f])=>`${p}: ${f}`).join(" | ");e.push(`- **CSS vars:** ${n}`)}s.framework&&e.push(`- **Framework:** ${s.framework}`),e.push("","> \u26A0 Always match these exactly. Do not introduce new colors or fonts.","")}return O.length&&(e.push("## Capability contract",""),e.push(`Project: **${j}** v${E}`),e.push(`Capabilities: ${O.join(", ")}`),e.push("")),e.push("---"),e.push(`_Session #${L} \xB7 ${k} \xB7 Generated by infernoflow._`),e.join(`
6
+ `)}async function Tt(t){const i=r=>t.includes(r),l=r=>{const h=t.indexOf(r);return h!==-1&&t[h+1]?t[h+1]:null},o=i("--show")||i("-s"),c=i("--copy")||i("-c"),s=i("--json"),A=i("--all"),D=l("--since"),a=l("--to")||t.find(r=>!r.startsWith("-")&&!["switch"].includes(r))||null;console.log(`
7
+ `+V("\u{1F525} infernoflow \u2014 switch")),console.log(" "+"\u2500".repeat(50)+`
8
+ `);const N=m.join(process.cwd(),".ai-memory");if(!b.existsSync(T)&&!b.existsSync(N)&&(console.error(pt(` \u2718 not initialized \u2014 run: infernoflow init
9
+ `)),process.exit(1)),o){const r=mt(gt);if(!r){console.log(J(` \u26A0 No HANDOFF.md yet \u2014 run: infernoflow switch
10
+ `));return}console.log(r);return}const H=St(a,D,A);if(s){const r=u(K)||{},h=u(X)||{},v=u(q),G=u(Q),W=tt(),g=et(W,D,A),st=W.filter(P=>new Date(P.ts||0)>g),w=nt(g.getTime()>0?g:null),B=ot();console.log(JSON.stringify({state:r,contract:{policyId:h.policyId,policyVersion:h.policyVersion,capabilities:h.capabilities},theme:v,adoption:G,sessions:st,commits:w,ide:B,sessionStart:g.toISOString(),sessionId:g.getTime()>0?g.getTime().toString(16).slice(-6).toUpperCase():"ALL",sessionDuration:Z(g.getTime()>0?Date.now()-g.getTime():-1),generatedAt:new Date().toISOString()},null,2));return}b.writeFileSync(z().handoff,H,"utf8"),console.log(rt(" \u2714 Written \u2192 "+m.relative(process.cwd(),z().handoff)+`
11
+ `));const I=tt(),d=et(I,D,A),j=I.filter(r=>new Date(r.ts||0)>d),E=u(K)||{},O=u(q),x=u(X)||{},M=nt(d.getTime()>0?d:null),k=ct(d.getTime()>0?d:null),L=ot(),C=j.length>0?j:I.slice(-5),_=lt(C),R=Z(d.getTime()>0?Date.now()-d.getTime():-1),y=d.getTime()>0?d.getTime().toString(16).slice(-6).toUpperCase():"ALL";if(console.log(" "+V("Handoff ready")),console.log(" "+"\u2500".repeat(50)),console.log(" "+it("Session #"+y+" \xB7 "+R)),E.working&&console.log(" Working on "+$(E.working)),E.intent&&console.log(" Intent "+$(E.intent)),console.log(" Memory "+j.length+" entries this session (total: "+I.length+")"),_.length&&console.log(" Open threads "+J(_.length+" unresolved")),M.length&&console.log(" Git commits "+M.length+" this session"),k.length&&console.log(" Hot files "+k.map(r=>$(r.file)).join(", ")),console.log(" Capabilities "+(x.capabilities||[]).length+" registered"),O?.fonts?.primary&&console.log(" Font "+O.fonts.primary),O?.colors?.mode&&console.log(" Color mode "+O.colors.mode),L&&console.log(" IDE "+L),a&&console.log(" Handing off \u2192 "+$(a)),console.log(),c){const r=yt(H);console.log(r?rt(" \u2714 Copied to clipboard \u2014 paste at the start of your next AI session"):J(" \u26A0 Clipboard failed \u2014 open inferno/HANDOFF.md manually"))}else console.log(" "+V("Ready to use:")),console.log(" "+$("1.")+" Open "+$("inferno/HANDOFF.md")),console.log(" "+$("2.")+" Copy all"),console.log(" "+$("3.")+" Paste at the start of your next AI session"),console.log(" "+it(" tip: use --copy to skip steps 1-2 automatically"));if(console.log(),b.existsSync(z().sessions)){const r={ts:new Date().toISOString(),agent:"infernoflow",type:"handoff",summary:a?`Handed off to ${a}`:"Handoff generated"};ht(process.cwd(),r)}}export{Tt as switchCommand};
@@ -1,471 +1,517 @@
1
- import { execSync } from "node:child_process";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
- import readline from "node:readline";
5
-
6
- function send(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); }
7
- function sendResult(id, result) { send({ jsonrpc: "2.0", id, result }); }
8
- function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
9
-
10
- function runCmd(args, env = {}) {
11
- try { return execSync(`npx infernoflow ${args}`, { encoding: "utf8", cwd: process.cwd(), timeout: 30000, env: { ...process.env, ...env } }); }
12
- catch (err) { return err.stdout || err.message; }
13
- }
14
-
15
- const TOOLS = [
16
- { name: "infernoflow_run", description: "Generate an infernoflow task prompt. Returns the prompt — respond to it with JSON, then call infernoflow_apply.", inputSchema: { type: "object", properties: { task: { type: "string", description: "What to build" } }, required: ["task"] } },
17
- { name: "infernoflow_apply", description: "Apply an infernoflow suggestion JSON returned by the agent. Call this after responding to infernoflow_run.", inputSchema: { type: "object", properties: { json: { type: "string", description: "The JSON suggestion from the agent" } }, required: ["json"] } },
18
- { name: "infernoflow_check", description: "Validate infernoflow contract and capabilities", inputSchema: { type: "object", properties: {} } },
19
- { name: "infernoflow_status", description: "Show contract health at a glance", inputSchema: { type: "object", properties: {} } },
20
- { name: "infernoflow_context", description: "Generate AI-ready context", inputSchema: { type: "object", properties: { intent: { type: "string" }, working: { type: "string" } } } },
21
- { name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes. Compares git-changed files to the capability registry and returns suggestions for contract updates.", inputSchema: { type: "object", properties: { sinceCommits: { type: "number", description: "How many commits back to check (default: 1)" } } } },
22
- { name: "infernoflow_implement", description: "Generate a structured code implementation prompt for a task. Uses the contract and stack context to produce step-by-step coding instructions for the agent.", inputSchema: { type: "object", properties: { task: { type: "string", description: "What to implement" }, mode: { type: "string", enum: ["cursor", "generic", "both"], description: "Prompt style (default: both)" } }, required: ["task"] } },
23
- { name: "infernoflow_scan_ui", description: "Scan components and styles for UI changes vs the stored contract. Returns new/changed components, design token changes, and suggested contract updates.", inputSchema: { type: "object", properties: {} } },
24
- { name: "infernoflow_review", description: "Pre-merge capability drift check. Compares all changed files in the current branch against the capability contract and reports drift risk before you merge.", inputSchema: { type: "object", properties: { branch: { type: "string", description: "Branch to compare against (default: main)" } } } },
25
- ];
26
-
27
- // ── git drift detection (inline no external imports in this template file) ─
28
- function detectGitDrift(sinceCommits) {
29
- const cwd = process.cwd();
30
- const infernoDir = path.join(cwd, "inferno");
31
-
32
- const runGit = (cmd) => {
33
- try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 10_000 }); }
34
- catch { return ""; }
35
- };
36
-
37
- const changedSet = new Set();
38
- const addLines = (out) => out.split("\n").map(l => l.trim()).filter(Boolean).forEach(f => changedSet.add(f));
39
-
40
- addLines(runGit("git diff --name-only HEAD"));
41
- addLines(runGit(`git diff --name-only HEAD~${sinceCommits} HEAD`));
42
- addLines(runGit("git ls-files --others --exclude-standard"));
43
-
44
- const changedFiles = Array.from(changedSet).sort();
45
- if (!changedFiles.length) return "No changed files detected since last commit.";
46
-
47
- // Load capabilities registry
48
- let capabilities = [];
49
- try {
50
- const capsPath = path.join(infernoDir, "capabilities.json");
51
- if (fs.existsSync(capsPath)) capabilities = JSON.parse(fs.readFileSync(capsPath, "utf8")).capabilities || [];
52
- } catch {}
53
-
54
- // Load capability-map if present
55
- let capMap = null;
56
- try {
57
- const mapPath = path.join(infernoDir, "capability-map.json");
58
- if (fs.existsSync(mapPath)) capMap = JSON.parse(fs.readFileSync(mapPath, "utf8"));
59
- } catch {}
60
-
61
- const capHits = new Map();
62
- const mappedFiles = new Set();
63
-
64
- const addHit = (capId, capTitle, file) => {
65
- if (!capHits.has(capId)) capHits.set(capId, { id: capId, title: capTitle || capId, files: new Set() });
66
- capHits.get(capId).files.add(file);
67
- mappedFiles.add(file);
68
- };
69
-
70
- // Strategy 1: capability-map.json
71
- if (capMap) {
72
- for (const file of changedFiles) {
73
- for (const [prefix, capIds] of Object.entries(capMap)) {
74
- if (file.startsWith(prefix.replace(/\\/g, "/"))) {
75
- for (const capId of capIds) {
76
- const cap = capabilities.find(c => c.id === capId);
77
- addHit(capId, cap?.title, file);
78
- }
79
- }
80
- }
81
- }
82
- }
83
-
84
- // Strategy 2: heuristic keyword matching on filename
85
- const RULES = [
86
- { kw: ["search"], id: "SearchItems" }, { kw: ["filter"], id: "FilterItems" },
87
- { kw: ["auth", "login", "logout"], id: "Authentication" },
88
- { kw: ["create", "add", "new"], id: "CreateItem" },
89
- { kw: ["update", "edit"], id: "UpdateItem" },
90
- { kw: ["delete", "remove"], id: "DeleteItem" },
91
- { kw: ["list", "read", "view"], id: "ReadItems" },
92
- { kw: ["due", "deadline"], id: "SetDueDate" },
93
- { kw: ["priority"], id: "SetPriority" },
94
- { kw: ["complete", "toggle"], id: "ToggleComplete" },
95
- ];
96
- for (const file of changedFiles) {
97
- if (mappedFiles.has(file)) continue;
98
- const lower = file.toLowerCase();
99
- for (const rule of RULES) {
100
- if (rule.kw.some(k => lower.includes(k))) {
101
- const cap = capabilities.find(c => c.id === rule.id);
102
- addHit(rule.id, cap?.title, file);
103
- break;
104
- }
105
- }
106
- }
107
-
108
- const unmapped = changedFiles.filter(f => !mappedFiles.has(f));
109
- const affected = Array.from(capHits.values());
110
-
111
- // Format output
112
- const lines = [
113
- `## infernoflow git drift report`,
114
- `Changed files: ${changedFiles.length}`,
115
- `Affected capabilities: ${affected.length}`,
116
- "",
117
- ];
118
-
119
- if (affected.length) {
120
- lines.push("### Capabilities likely needing contract review:");
121
- for (const cap of affected) {
122
- lines.push(`\n**${cap.id}** ${cap.title}`);
123
- for (const f of cap.files) lines.push(` - ${f}`);
124
- }
125
- lines.push("");
126
- lines.push("### Suggested action:");
127
- lines.push(`Call infernoflow_run with task "review changes to ${affected.map(c => c.id).join(", ")}" to update the contract.`);
128
- } else {
129
- lines.push("No capability matches found for changed files.");
130
- lines.push("Consider updating inferno/capability-map.json to map your source paths to capabilities.");
131
- }
132
-
133
- if (unmapped.length) {
134
- lines.push(`\n### Unmapped changed files (${unmapped.length}):`);
135
- for (const f of unmapped.slice(0, 10)) lines.push(` - ${f}`);
136
- if (unmapped.length > 10) lines.push(` ... +${unmapped.length - 10} more`);
137
- }
138
-
139
- return lines.join("\n");
140
- }
141
-
142
- // ── infernoflow_scan_ui ────────────────────────────────────────────────────
143
- function scanUi() {
144
- const cwd = process.cwd();
145
- const infernoDir = path.join(cwd, "inferno");
146
- const contractPath = path.join(infernoDir, "contract.json");
147
- if (!fs.existsSync(contractPath)) return "inferno/ not found — run infernoflow init first";
148
-
149
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
150
- const storedUi = contract.ui || {};
151
-
152
- // Collect style + component files
153
- const styleExts = /\.(css|scss|sass|less|ts|tsx|js|jsx|html)$/;
154
- const SKIP = new Set(["node_modules", ".git", "dist", "build", ".angular", ".next", "vendor", "coverage"]);
155
- const files = [];
156
- const walk = (dir) => {
157
- try {
158
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
159
- const full = path.join(dir, entry.name);
160
- if (entry.isDirectory()) { if (!SKIP.has(entry.name)) walk(full); }
161
- else if (styleExts.test(entry.name) && !entry.name.includes(".min.") && !entry.name.endsWith(".map")) files.push(full);
162
- }
163
- } catch {}
164
- };
165
- for (const root of ["src", "app", "frontend", "components", "styles"]) {
166
- const p = path.join(cwd, root);
167
- if (fs.existsSync(p)) walk(p);
168
- }
169
-
170
- // Extract current components from TS/TSX files
171
- const currentComponents = new Set();
172
- const currentTokens = new Set();
173
-
174
- for (const f of files) {
175
- const text = fs.existsSync(f) ? fs.readFileSync(f, "utf8") : "";
176
- // Components
177
- for (const m of text.matchAll(/@Component[\s\S]*?class\s+([A-Z][A-Za-z0-9_]*Component)/g)) currentComponents.add(m[1].replace(/Component$/, ""));
178
- for (const m of text.matchAll(/export\s+(?:default\s+)?function\s+([A-Z][A-Za-z0-9_]*)/g)) currentComponents.add(m[1]);
179
- // Design tokens
180
- for (const m of text.matchAll(/--([a-zA-Z][a-zA-Z0-9_-]*)\s*:/g)) currentTokens.add(`--${m[1]}`);
181
- }
182
-
183
- const storedComponents = new Set(storedUi.components || []);
184
- const storedTokens = new Set(storedUi.designTokens || []);
185
-
186
- const newComponents = [...currentComponents].filter(c => !storedComponents.has(c));
187
- const removedComponents = [...storedComponents].filter(c => !currentComponents.has(c));
188
- const newTokens = [...currentTokens].filter(t => !storedTokens.has(t));
189
- const removedTokens = [...storedTokens].filter(t => !currentTokens.has(t));
190
-
191
- const lines = ["## infernoflow UI scan report", ""];
192
-
193
- if (!newComponents.length && !removedComponents.length && !newTokens.length && !removedTokens.length) {
194
- lines.push("✔ No UI changes detected since last scan.");
195
- return lines.join("\n");
196
- }
197
-
198
- if (newComponents.length) {
199
- lines.push(`### New components (${newComponents.length})`);
200
- newComponents.slice(0, 15).forEach(c => lines.push(` + ${c}`));
201
- lines.push("");
202
- }
203
- if (removedComponents.length) {
204
- lines.push(`### Removed components (${removedComponents.length})`);
205
- removedComponents.slice(0, 10).forEach(c => lines.push(` - ${c}`));
206
- lines.push("");
207
- }
208
- if (newTokens.length) {
209
- lines.push(`### New design tokens (${newTokens.length})`);
210
- newTokens.slice(0, 10).forEach(t => lines.push(` + ${t}`));
211
- lines.push("");
212
- }
213
- if (removedTokens.length) {
214
- lines.push(`### Removed design tokens (${removedTokens.length})`);
215
- removedTokens.slice(0, 10).forEach(t => lines.push(` - ${t}`));
216
- lines.push("");
217
- }
218
-
219
- lines.push("### Suggested action");
220
- if (newComponents.length) {
221
- const newCaps = newComponents.slice(0, 5).map(c => `View${c}`).join(", ");
222
- lines.push(`Consider adding these capabilities: ${newCaps}`);
223
- lines.push(`Call infernoflow_run with task "add UI capabilities for new components: ${newComponents.slice(0,3).join(", ")}" to update the contract.`);
224
- }
225
-
226
- return lines.join("\n");
227
- }
228
-
229
- // ── infernoflow_review ─────────────────────────────────────────────────────
230
- function reviewDrift(baseBranch) {
231
- const cwd = process.cwd();
232
- const infernoDir = path.join(cwd, "inferno");
233
- const contractPath = path.join(infernoDir, "contract.json");
234
- if (!fs.existsSync(contractPath)) return "inferno/ not found — run infernoflow init first";
235
-
236
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
237
-
238
- // Get changed files vs base branch
239
- const runGit = (cmd) => { try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 15_000 }); } catch { return ""; } };
240
-
241
- const diffOutput = runGit(`git diff --name-only ${baseBranch}...HEAD`);
242
- const changedFiles = diffOutput.split("\n").map(l => l.trim()).filter(Boolean);
243
-
244
- if (!changedFiles.length) return `No changes detected vs ${baseBranch}. Safe to merge.`;
245
-
246
- // Categorise changed files
247
- const infraFiles = changedFiles.filter(f => /\.(json|yaml|yml|env|config|lock)$/.test(f) || f.includes("inferno/"));
248
- const sourceFiles = changedFiles.filter(f => /\.(ts|tsx|js|jsx|mjs|cs|py|go|java)$/.test(f));
249
- const styleFiles = changedFiles.filter(f => /\.(css|scss|sass|less)$/.test(f));
250
- const contractChanged = changedFiles.some(f => f.startsWith("inferno/"));
251
-
252
- // Keyword-based drift detection on changed source files
253
- const HEURISTICS = [
254
- { kw: ["search"], id: "SearchItems" }, { kw: ["filter"], id: "FilterItems" },
255
- { kw: ["auth", "login", "logout"], id: "Authentication" },
256
- { kw: ["create", "add", "new"], id: "CreateItem" },
257
- { kw: ["update", "edit", "patch"], id: "UpdateItem" },
258
- { kw: ["delete", "remove"], id: "DeleteItem" },
259
- { kw: ["list", "read", "fetch", "get"], id: "ReadItems" },
260
- { kw: ["due", "deadline"], id: "SetDueDate" },
261
- { kw: ["priority"], id: "SetPriority" },
262
- { kw: ["complete", "toggle"], id: "ToggleComplete" },
263
- { kw: ["export", "download"], id: "ExportData" },
264
- { kw: ["import", "upload"], id: "ImportData" },
265
- { kw: ["notify", "notification", "email"], id: "SendNotification" },
266
- { kw: ["payment", "checkout", "stripe"], id: "ProcessPayment" },
267
- ];
268
-
269
- const capHits = new Map();
270
- const registeredCaps = new Set(contract.capabilities || []);
271
-
272
- for (const file of sourceFiles) {
273
- const lower = file.toLowerCase();
274
- for (const rule of HEURISTICS) {
275
- if (rule.kw.some(k => lower.includes(k))) {
276
- if (!capHits.has(rule.id)) capHits.set(rule.id, []);
277
- capHits.get(rule.id).push(file);
278
- }
279
- }
280
- }
281
-
282
- const newCapSignals = [...capHits.entries()].filter(([id]) => !registeredCaps.has(id));
283
- const existingCapSignals = [...capHits.entries()].filter(([id]) => registeredCaps.has(id));
284
-
285
- const lines = [
286
- `## infernoflow PR review — drift check vs \`${baseBranch}\``,
287
- `Changed files: ${changedFiles.length} | Source: ${sourceFiles.length} | Styles: ${styleFiles.length} | Infra: ${infraFiles.length}`,
288
- "",
289
- ];
290
-
291
- // Risk assessment
292
- let riskLevel = "LOW";
293
- if (newCapSignals.length > 0) riskLevel = "MEDIUM";
294
- if (newCapSignals.length >= 3 || (newCapSignals.length >= 1 && !contractChanged)) riskLevel = "HIGH";
295
-
296
- const riskEmoji = riskLevel === "HIGH" ? "🔴" : riskLevel === "MEDIUM" ? "🟡" : "🟢";
297
- lines.push(`### ${riskEmoji} Drift risk: ${riskLevel}`);
298
- lines.push("");
299
-
300
- if (contractChanged) {
301
- lines.push("✔ inferno/ contract files were updated in this PR — good practice.");
302
- lines.push("");
303
- } else if (sourceFiles.length > 0) {
304
- lines.push("⚠ Source files changed but inferno/ contract was NOT updated.");
305
- lines.push(" Consider running: infernoflow_run to check if capabilities need updating.");
306
- lines.push("");
307
- }
308
-
309
- if (newCapSignals.length > 0) {
310
- lines.push(`### Possible new capabilities (not in contract):`);
311
- for (const [id, files] of newCapSignals.slice(0, 6)) {
312
- lines.push(` - **${id}** — suggested by: ${files.slice(0,2).join(", ")}`);
313
- }
314
- lines.push("");
315
- lines.push(`Suggested action: call infernoflow_run with task "review new capabilities: ${newCapSignals.slice(0,3).map(([id])=>id).join(', ')}"`);
316
- lines.push("");
317
- }
318
-
319
- if (existingCapSignals.length > 0) {
320
- lines.push(`### Existing capabilities touched:`);
321
- for (const [id, files] of existingCapSignals.slice(0, 6)) {
322
- lines.push(` - **${id}** — ${files.slice(0,2).join(", ")}`);
323
- }
324
- lines.push("");
325
- }
326
-
327
- if (styleFiles.length > 0) {
328
- lines.push(`### Style changes (${styleFiles.length} files) — run infernoflow_scan_ui to check UI contract`);
329
- styleFiles.slice(0, 5).forEach(f => lines.push(` - ${f}`));
330
- lines.push("");
331
- }
332
-
333
- if (riskLevel === "LOW" && !newCapSignals.length) {
334
- lines.push("✔ No new capability signals detected. Safe to merge (run infernoflow_check as final gate).");
335
- }
336
-
337
- return lines.join("\n");
338
- }
339
-
340
- function buildImplementPrompt(task, mode) {
341
- const cwd = process.cwd();
342
- const infernoDir = path.join(cwd, "inferno");
343
- const contractPath = path.join(infernoDir, "contract.json");
344
- const capsPath = path.join(infernoDir, "capabilities.json");
345
- const profilePath = path.join(infernoDir, "developer-profile.json");
346
-
347
- if (!fs.existsSync(contractPath)) return "inferno/ not found — run infernoflow init first";
348
-
349
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
350
- const caps = fs.existsSync(capsPath) ? JSON.parse(fs.readFileSync(capsPath, "utf8")) : {};
351
- const profile = fs.existsSync(profilePath) ? JSON.parse(fs.readFileSync(profilePath, "utf8")) : {};
352
-
353
- const capList = (caps.capabilities || []).map(c => ` - ${c.id}: ${c.title || c.id}`).join("\n");
354
- const stack = profile.stack || {};
355
- const stackLine = [stack.framework, stack.language, stack.projectType].filter(Boolean).join(" / ") || "unknown";
356
- const namingStyle = profile.namingStyle || "PascalCase";
357
-
358
- const cursorPrompt = `## Cursor Agent Implementation Prompt
359
- Task: "${task}"
360
- Project: ${contract.policyId} (${stackLine})
361
- Naming convention: ${namingStyle}
362
-
363
- ### Current capabilities
364
- ${capList || " (none registered)"}
365
-
366
- ### Implementation instructions
367
- 1. Implement "${task}" following the existing code patterns in this project
368
- 2. Use ${namingStyle} for any new identifiers, matching the existing capability naming
369
- 3. Keep changes minimal — only touch files relevant to this task
370
- 4. After implementing, call \`infernoflow_run\` with task "${task}" to update the contract
371
- 5. Then call \`infernoflow_check\` to validate everything is in sync
372
-
373
- ### Definition of done
374
- - Feature works as described
375
- - Contract updated via infernoflow_run → infernoflow_apply
376
- - infernoflow_check passes`;
377
-
378
- const genericPrompt = `## Implementation Prompt
379
- Task: "${task}"
380
- Project: ${contract.policyId}
381
- Stack: ${stackLine}
382
- Capabilities already in contract: ${(contract.capabilities || []).join(", ")}
383
-
384
- Implement the task above. When done, run:
385
- infernoflow suggest "${task}"
386
- infernoflow check`;
387
-
388
- if (mode === "cursor") return cursorPrompt;
389
- if (mode === "generic") return genericPrompt;
390
- return cursorPrompt + "\n\n---\n\n" + genericPrompt;
391
- }
392
-
393
- function buildPrompt(task) {
394
- const infernoDir = path.join(process.cwd(), "inferno");
395
- const contractPath = path.join(infernoDir, "contract.json");
396
- const capsPath = path.join(infernoDir, "capabilities.json");
397
- if (!fs.existsSync(contractPath)) return null;
398
- const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
399
- const caps = fs.existsSync(capsPath) ? JSON.parse(fs.readFileSync(capsPath, "utf8")) : {};
400
- const capList = (caps.capabilities || []).map(c => ` - ${c.id}: ${c.title || c.id}`).join("\n");
401
- return `You are a developer assistant for the infernoflow CLI tool.
402
- Analyze this task and suggest updates to the infernoflow contract files.
403
-
404
- ## Current contract
405
- policyId: ${contract.policyId}
406
- policyVersion: ${contract.policyVersion}
407
- capabilities: [${(contract.capabilities || []).join(", ")}]
408
-
409
- ## Capabilities registry
410
- ${capList || " (none)"}
411
-
412
- ## Task
413
- "${task}"
414
-
415
- ## Instructions
416
- Respond with ONLY a valid JSON object:
417
- {
418
- "summary": "one-line summary of what changed",
419
- "newCapabilities": [{ "id": "PascalCase", "title": "Human readable title", "reason": "why this is new" }],
420
- "removedCapabilities": [],
421
- "updatedScenarios": [],
422
- "changelogEntry": "- Short description for CHANGELOG.md"
423
- }`;
424
- }
425
-
426
- function handleTool(id, name, input) {
427
- try {
428
- let text = "";
429
- if (name === "infernoflow_run") {
430
- const prompt = buildPrompt(input.task);
431
- if (!prompt) { sendError(id, -32000, "inferno/ not found — run infernoflow init first"); return; }
432
- const promptFile = path.join(process.cwd(), "inferno", "agent-prompt.md");
433
- fs.writeFileSync(promptFile, prompt, "utf8");
434
- text = `## infernoflow task: "${input.task}"\n\n${prompt}\n\n---\nRespond with the JSON, then call **infernoflow_apply** with your JSON string.`;
435
- } else if (name === "infernoflow_apply") {
436
- const responseFile = path.join(process.cwd(), "inferno", "agent-response.json");
437
- let json = input.json.trim().replace(/^```json?\n?/, "").replace(/\n?```$/, "");
438
- fs.writeFileSync(responseFile, json, "utf8");
439
- text = runCmd(`run "apply"`, { INFERNO_AGENT_RESPONSE_FILE: responseFile, INFERNO_AGENT_AVAILABLE: "1" });
440
- } else if (name === "infernoflow_check") {
441
- text = runCmd("check");
442
- } else if (name === "infernoflow_status") {
443
- text = runCmd("status");
444
- } else if (name === "infernoflow_context") {
445
- const parts = [];
446
- if (input.intent) parts.push(`--intent "${input.intent}"`);
447
- if (input.working) parts.push(`--working "${input.working}"`);
448
- text = runCmd("context " + parts.join(" "));
449
- } else if (name === "infernoflow_git_drift") {
450
- text = detectGitDrift(input.sinceCommits || 1);
451
- } else if (name === "infernoflow_implement") {
452
- text = buildImplementPrompt(input.task, input.mode || "both");
453
- } else if (name === "infernoflow_scan_ui") {
454
- text = scanUi();
455
- } else if (name === "infernoflow_review") {
456
- text = reviewDrift(input.branch || "main");
457
- } else { return sendError(id, -32601, `Unknown tool: ${name}`); }
458
- sendResult(id, { content: [{ type: "text", text: text || "(no output)" }] });
459
- } catch (err) { sendError(id, -32000, err.message); }
460
- }
461
-
462
- const rl = readline.createInterface({ input: process.stdin });
463
- rl.on("line", (line) => {
464
- let msg; try { msg = JSON.parse(line); } catch { return; }
465
- const { id, method, params } = msg;
466
- if (method === "initialize") { sendResult(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "infernoflow", version: "1.0.0" } }); return; }
467
- if (method === "tools/list") { sendResult(id, { tools: TOOLS }); return; }
468
- if (method === "tools/call") { handleTool(id, params.name, params.arguments || {}); return; }
469
- if (id !== undefined) sendError(id, -32601, `Method not found: ${method}`);
470
- });
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import readline from "node:readline";
5
+
6
+ function send(obj) { process.stdout.write(JSON.stringify(obj) + "\n"); }
7
+ function sendResult(id, result) { send({ jsonrpc: "2.0", id, result }); }
8
+ function sendError(id, code, message) { send({ jsonrpc: "2.0", id, error: { code, message } }); }
9
+
10
+ function runCmd(args, env = {}) {
11
+ try { return execSync(`npx infernoflow ${args}`, { encoding: "utf8", cwd: process.cwd(), timeout: 30000, env: { ...process.env, ...env } }); }
12
+ catch (err) { return err.stdout || err.message; }
13
+ }
14
+
15
+ const TOOLS = [
16
+ { name: "infernoflow_run", description: "Generate an infernoflow task prompt. Returns the prompt — respond to it with JSON, then call infernoflow_apply.", inputSchema: { type: "object", properties: { task: { type: "string", description: "What to build" } }, required: ["task"] } },
17
+ { name: "infernoflow_apply", description: "Apply an infernoflow suggestion JSON returned by the agent. Call this after responding to infernoflow_run.", inputSchema: { type: "object", properties: { json: { type: "string", description: "The JSON suggestion from the agent" } }, required: ["json"] } },
18
+ { name: "infernoflow_check", description: "Validate infernoflow contract and capabilities", inputSchema: { type: "object", properties: {} } },
19
+ { name: "infernoflow_status", description: "Show contract health at a glance", inputSchema: { type: "object", properties: {} } },
20
+ { name: "infernoflow_context", description: "Generate AI-ready context", inputSchema: { type: "object", properties: { intent: { type: "string" }, working: { type: "string" } } } },
21
+ { name: "infernoflow_git_drift", description: "Detect which capabilities may be affected by recent code changes. Compares git-changed files to the capability registry and returns suggestions for contract updates.", inputSchema: { type: "object", properties: { sinceCommits: { type: "number", description: "How many commits back to check (default: 1)" } } } },
22
+ { name: "infernoflow_implement", description: "Generate a structured code implementation prompt for a task. Uses the contract and stack context to produce step-by-step coding instructions for the agent.", inputSchema: { type: "object", properties: { task: { type: "string", description: "What to implement" }, mode: { type: "string", enum: ["cursor", "generic", "both"], description: "Prompt style (default: both)" } }, required: ["task"] } },
23
+ { name: "infernoflow_scan_ui", description: "Scan components and styles for UI changes vs the stored contract. Returns new/changed components, design token changes, and suggested contract updates.", inputSchema: { type: "object", properties: {} } },
24
+ { name: "infernoflow_review", description: "Pre-merge capability drift check. Compares all changed files in the current branch against the capability contract and reports drift risk before you merge.", inputSchema: { type: "object", properties: { branch: { type: "string", description: "Branch to compare against (default: main)" } } } },
25
+
26
+ // ── AMP-spec MCP tools (per docs/protocol/PROTOCOL.md §7.3) ────────────────
27
+ // These are the standard names any AMP-compliant MCP server should expose.
28
+ // They're thin wrappers around the existing infernoflow_* tools so AMP-only
29
+ // clients don't need to know the infernoflow_ vendor prefix.
30
+ { name: "amp_read", description: "AMP: read session memory entries with optional filters.", inputSchema: { type: "object", properties: { file: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, query: { type: "string" }, limit: { type: "number" } } } },
31
+ { name: "amp_write", description: "AMP: log a new entry. Required: type + msg. Optional: file, line, tags.", inputSchema: { type: "object", properties: { type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] }, msg: { type: "string" }, file: { type: "string" }, line: { type: "number" }, tags: { type: "array", items: { type: "string" } } }, required: ["type","msg"] } },
32
+ { name: "amp_handoff", description: "AMP: generate the handoff document for the next AI session. format=markdown|json (default: markdown).", inputSchema: { type: "object", properties: { format: { type: "string", enum: ["markdown","json"] } } } },
33
+ { name: "amp_search", description: "AMP: search entries by keyword. Optional type filter.", inputSchema: { type: "object", properties: { query: { type: "string" }, type: { type: "string", enum: ["gotcha","decision","attempt","note","detection","pattern"] } }, required: ["query"] } },
34
+ { name: "amp_health", description: "AMP: get the session health score (0-100, A-F grade).", inputSchema: { type: "object", properties: {} } },
35
+ ];
36
+
37
+ // ── git drift detection (inline — no external imports in this template file)
38
+ function detectGitDrift(sinceCommits) {
39
+ const cwd = process.cwd();
40
+ const infernoDir = path.join(cwd, "inferno");
41
+
42
+ const runGit = (cmd) => {
43
+ try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 10_000 }); }
44
+ catch { return ""; }
45
+ };
46
+
47
+ const changedSet = new Set();
48
+ const addLines = (out) => out.split("\n").map(l => l.trim()).filter(Boolean).forEach(f => changedSet.add(f));
49
+
50
+ addLines(runGit("git diff --name-only HEAD"));
51
+ addLines(runGit(`git diff --name-only HEAD~${sinceCommits} HEAD`));
52
+ addLines(runGit("git ls-files --others --exclude-standard"));
53
+
54
+ const changedFiles = Array.from(changedSet).sort();
55
+ if (!changedFiles.length) return "No changed files detected since last commit.";
56
+
57
+ // Load capabilities registry
58
+ let capabilities = [];
59
+ try {
60
+ const capsPath = path.join(infernoDir, "capabilities.json");
61
+ if (fs.existsSync(capsPath)) capabilities = JSON.parse(fs.readFileSync(capsPath, "utf8")).capabilities || [];
62
+ } catch {}
63
+
64
+ // Load capability-map if present
65
+ let capMap = null;
66
+ try {
67
+ const mapPath = path.join(infernoDir, "capability-map.json");
68
+ if (fs.existsSync(mapPath)) capMap = JSON.parse(fs.readFileSync(mapPath, "utf8"));
69
+ } catch {}
70
+
71
+ const capHits = new Map();
72
+ const mappedFiles = new Set();
73
+
74
+ const addHit = (capId, capTitle, file) => {
75
+ if (!capHits.has(capId)) capHits.set(capId, { id: capId, title: capTitle || capId, files: new Set() });
76
+ capHits.get(capId).files.add(file);
77
+ mappedFiles.add(file);
78
+ };
79
+
80
+ // Strategy 1: capability-map.json
81
+ if (capMap) {
82
+ for (const file of changedFiles) {
83
+ for (const [prefix, capIds] of Object.entries(capMap)) {
84
+ if (file.startsWith(prefix.replace(/\\/g, "/"))) {
85
+ for (const capId of capIds) {
86
+ const cap = capabilities.find(c => c.id === capId);
87
+ addHit(capId, cap?.title, file);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ }
93
+
94
+ // Strategy 2: heuristic keyword matching on filename
95
+ const RULES = [
96
+ { kw: ["search"], id: "SearchItems" }, { kw: ["filter"], id: "FilterItems" },
97
+ { kw: ["auth", "login", "logout"], id: "Authentication" },
98
+ { kw: ["create", "add", "new"], id: "CreateItem" },
99
+ { kw: ["update", "edit"], id: "UpdateItem" },
100
+ { kw: ["delete", "remove"], id: "DeleteItem" },
101
+ { kw: ["list", "read", "view"], id: "ReadItems" },
102
+ { kw: ["due", "deadline"], id: "SetDueDate" },
103
+ { kw: ["priority"], id: "SetPriority" },
104
+ { kw: ["complete", "toggle"], id: "ToggleComplete" },
105
+ ];
106
+ for (const file of changedFiles) {
107
+ if (mappedFiles.has(file)) continue;
108
+ const lower = file.toLowerCase();
109
+ for (const rule of RULES) {
110
+ if (rule.kw.some(k => lower.includes(k))) {
111
+ const cap = capabilities.find(c => c.id === rule.id);
112
+ addHit(rule.id, cap?.title, file);
113
+ break;
114
+ }
115
+ }
116
+ }
117
+
118
+ const unmapped = changedFiles.filter(f => !mappedFiles.has(f));
119
+ const affected = Array.from(capHits.values());
120
+
121
+ // Format output
122
+ const lines = [
123
+ `## infernoflow git drift report`,
124
+ `Changed files: ${changedFiles.length}`,
125
+ `Affected capabilities: ${affected.length}`,
126
+ "",
127
+ ];
128
+
129
+ if (affected.length) {
130
+ lines.push("### Capabilities likely needing contract review:");
131
+ for (const cap of affected) {
132
+ lines.push(`\n**${cap.id}** — ${cap.title}`);
133
+ for (const f of cap.files) lines.push(` - ${f}`);
134
+ }
135
+ lines.push("");
136
+ lines.push("### Suggested action:");
137
+ lines.push(`Call infernoflow_run with task "review changes to ${affected.map(c => c.id).join(", ")}" to update the contract.`);
138
+ } else {
139
+ lines.push("No capability matches found for changed files.");
140
+ lines.push("Consider updating inferno/capability-map.json to map your source paths to capabilities.");
141
+ }
142
+
143
+ if (unmapped.length) {
144
+ lines.push(`\n### Unmapped changed files (${unmapped.length}):`);
145
+ for (const f of unmapped.slice(0, 10)) lines.push(` - ${f}`);
146
+ if (unmapped.length > 10) lines.push(` ... +${unmapped.length - 10} more`);
147
+ }
148
+
149
+ return lines.join("\n");
150
+ }
151
+
152
+ // ── infernoflow_scan_ui ────────────────────────────────────────────────────
153
+ function scanUi() {
154
+ const cwd = process.cwd();
155
+ const infernoDir = path.join(cwd, "inferno");
156
+ const contractPath = path.join(infernoDir, "contract.json");
157
+ if (!fs.existsSync(contractPath)) return "inferno/ not found — run infernoflow init first";
158
+
159
+ const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
160
+ const storedUi = contract.ui || {};
161
+
162
+ // Collect style + component files
163
+ const styleExts = /\.(css|scss|sass|less|ts|tsx|js|jsx|html)$/;
164
+ const SKIP = new Set(["node_modules", ".git", "dist", "build", ".angular", ".next", "vendor", "coverage"]);
165
+ const files = [];
166
+ const walk = (dir) => {
167
+ try {
168
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
169
+ const full = path.join(dir, entry.name);
170
+ if (entry.isDirectory()) { if (!SKIP.has(entry.name)) walk(full); }
171
+ else if (styleExts.test(entry.name) && !entry.name.includes(".min.") && !entry.name.endsWith(".map")) files.push(full);
172
+ }
173
+ } catch {}
174
+ };
175
+ for (const root of ["src", "app", "frontend", "components", "styles"]) {
176
+ const p = path.join(cwd, root);
177
+ if (fs.existsSync(p)) walk(p);
178
+ }
179
+
180
+ // Extract current components from TS/TSX files
181
+ const currentComponents = new Set();
182
+ const currentTokens = new Set();
183
+
184
+ for (const f of files) {
185
+ const text = fs.existsSync(f) ? fs.readFileSync(f, "utf8") : "";
186
+ // Components
187
+ for (const m of text.matchAll(/@Component[\s\S]*?class\s+([A-Z][A-Za-z0-9_]*Component)/g)) currentComponents.add(m[1].replace(/Component$/, ""));
188
+ for (const m of text.matchAll(/export\s+(?:default\s+)?function\s+([A-Z][A-Za-z0-9_]*)/g)) currentComponents.add(m[1]);
189
+ // Design tokens
190
+ for (const m of text.matchAll(/--([a-zA-Z][a-zA-Z0-9_-]*)\s*:/g)) currentTokens.add(`--${m[1]}`);
191
+ }
192
+
193
+ const storedComponents = new Set(storedUi.components || []);
194
+ const storedTokens = new Set(storedUi.designTokens || []);
195
+
196
+ const newComponents = [...currentComponents].filter(c => !storedComponents.has(c));
197
+ const removedComponents = [...storedComponents].filter(c => !currentComponents.has(c));
198
+ const newTokens = [...currentTokens].filter(t => !storedTokens.has(t));
199
+ const removedTokens = [...storedTokens].filter(t => !currentTokens.has(t));
200
+
201
+ const lines = ["## infernoflow UI scan report", ""];
202
+
203
+ if (!newComponents.length && !removedComponents.length && !newTokens.length && !removedTokens.length) {
204
+ lines.push("✔ No UI changes detected since last scan.");
205
+ return lines.join("\n");
206
+ }
207
+
208
+ if (newComponents.length) {
209
+ lines.push(`### New components (${newComponents.length})`);
210
+ newComponents.slice(0, 15).forEach(c => lines.push(` + ${c}`));
211
+ lines.push("");
212
+ }
213
+ if (removedComponents.length) {
214
+ lines.push(`### Removed components (${removedComponents.length})`);
215
+ removedComponents.slice(0, 10).forEach(c => lines.push(` - ${c}`));
216
+ lines.push("");
217
+ }
218
+ if (newTokens.length) {
219
+ lines.push(`### New design tokens (${newTokens.length})`);
220
+ newTokens.slice(0, 10).forEach(t => lines.push(` + ${t}`));
221
+ lines.push("");
222
+ }
223
+ if (removedTokens.length) {
224
+ lines.push(`### Removed design tokens (${removedTokens.length})`);
225
+ removedTokens.slice(0, 10).forEach(t => lines.push(` - ${t}`));
226
+ lines.push("");
227
+ }
228
+
229
+ lines.push("### Suggested action");
230
+ if (newComponents.length) {
231
+ const newCaps = newComponents.slice(0, 5).map(c => `View${c}`).join(", ");
232
+ lines.push(`Consider adding these capabilities: ${newCaps}`);
233
+ lines.push(`Call infernoflow_run with task "add UI capabilities for new components: ${newComponents.slice(0,3).join(", ")}" to update the contract.`);
234
+ }
235
+
236
+ return lines.join("\n");
237
+ }
238
+
239
+ // ── infernoflow_review ─────────────────────────────────────────────────────
240
+ function reviewDrift(baseBranch) {
241
+ const cwd = process.cwd();
242
+ const infernoDir = path.join(cwd, "inferno");
243
+ const contractPath = path.join(infernoDir, "contract.json");
244
+ if (!fs.existsSync(contractPath)) return "inferno/ not found run infernoflow init first";
245
+
246
+ const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
247
+
248
+ // Get changed files vs base branch
249
+ const runGit = (cmd) => { try { return execSync(cmd, { cwd, encoding: "utf8", timeout: 15_000 }); } catch { return ""; } };
250
+
251
+ const diffOutput = runGit(`git diff --name-only ${baseBranch}...HEAD`);
252
+ const changedFiles = diffOutput.split("\n").map(l => l.trim()).filter(Boolean);
253
+
254
+ if (!changedFiles.length) return `No changes detected vs ${baseBranch}. Safe to merge.`;
255
+
256
+ // Categorise changed files
257
+ const infraFiles = changedFiles.filter(f => /\.(json|yaml|yml|env|config|lock)$/.test(f) || f.includes("inferno/"));
258
+ const sourceFiles = changedFiles.filter(f => /\.(ts|tsx|js|jsx|mjs|cs|py|go|java)$/.test(f));
259
+ const styleFiles = changedFiles.filter(f => /\.(css|scss|sass|less)$/.test(f));
260
+ const contractChanged = changedFiles.some(f => f.startsWith("inferno/"));
261
+
262
+ // Keyword-based drift detection on changed source files
263
+ const HEURISTICS = [
264
+ { kw: ["search"], id: "SearchItems" }, { kw: ["filter"], id: "FilterItems" },
265
+ { kw: ["auth", "login", "logout"], id: "Authentication" },
266
+ { kw: ["create", "add", "new"], id: "CreateItem" },
267
+ { kw: ["update", "edit", "patch"], id: "UpdateItem" },
268
+ { kw: ["delete", "remove"], id: "DeleteItem" },
269
+ { kw: ["list", "read", "fetch", "get"], id: "ReadItems" },
270
+ { kw: ["due", "deadline"], id: "SetDueDate" },
271
+ { kw: ["priority"], id: "SetPriority" },
272
+ { kw: ["complete", "toggle"], id: "ToggleComplete" },
273
+ { kw: ["export", "download"], id: "ExportData" },
274
+ { kw: ["import", "upload"], id: "ImportData" },
275
+ { kw: ["notify", "notification", "email"], id: "SendNotification" },
276
+ { kw: ["payment", "checkout", "stripe"], id: "ProcessPayment" },
277
+ ];
278
+
279
+ const capHits = new Map();
280
+ const registeredCaps = new Set(contract.capabilities || []);
281
+
282
+ for (const file of sourceFiles) {
283
+ const lower = file.toLowerCase();
284
+ for (const rule of HEURISTICS) {
285
+ if (rule.kw.some(k => lower.includes(k))) {
286
+ if (!capHits.has(rule.id)) capHits.set(rule.id, []);
287
+ capHits.get(rule.id).push(file);
288
+ }
289
+ }
290
+ }
291
+
292
+ const newCapSignals = [...capHits.entries()].filter(([id]) => !registeredCaps.has(id));
293
+ const existingCapSignals = [...capHits.entries()].filter(([id]) => registeredCaps.has(id));
294
+
295
+ const lines = [
296
+ `## infernoflow PR review drift check vs \`${baseBranch}\``,
297
+ `Changed files: ${changedFiles.length} | Source: ${sourceFiles.length} | Styles: ${styleFiles.length} | Infra: ${infraFiles.length}`,
298
+ "",
299
+ ];
300
+
301
+ // Risk assessment
302
+ let riskLevel = "LOW";
303
+ if (newCapSignals.length > 0) riskLevel = "MEDIUM";
304
+ if (newCapSignals.length >= 3 || (newCapSignals.length >= 1 && !contractChanged)) riskLevel = "HIGH";
305
+
306
+ const riskEmoji = riskLevel === "HIGH" ? "🔴" : riskLevel === "MEDIUM" ? "🟡" : "🟢";
307
+ lines.push(`### ${riskEmoji} Drift risk: ${riskLevel}`);
308
+ lines.push("");
309
+
310
+ if (contractChanged) {
311
+ lines.push("✔ inferno/ contract files were updated in this PR — good practice.");
312
+ lines.push("");
313
+ } else if (sourceFiles.length > 0) {
314
+ lines.push("⚠ Source files changed but inferno/ contract was NOT updated.");
315
+ lines.push(" Consider running: infernoflow_run to check if capabilities need updating.");
316
+ lines.push("");
317
+ }
318
+
319
+ if (newCapSignals.length > 0) {
320
+ lines.push(`### Possible new capabilities (not in contract):`);
321
+ for (const [id, files] of newCapSignals.slice(0, 6)) {
322
+ lines.push(` - **${id}** — suggested by: ${files.slice(0,2).join(", ")}`);
323
+ }
324
+ lines.push("");
325
+ lines.push(`Suggested action: call infernoflow_run with task "review new capabilities: ${newCapSignals.slice(0,3).map(([id])=>id).join(', ')}"`);
326
+ lines.push("");
327
+ }
328
+
329
+ if (existingCapSignals.length > 0) {
330
+ lines.push(`### Existing capabilities touched:`);
331
+ for (const [id, files] of existingCapSignals.slice(0, 6)) {
332
+ lines.push(` - **${id}** — ${files.slice(0,2).join(", ")}`);
333
+ }
334
+ lines.push("");
335
+ }
336
+
337
+ if (styleFiles.length > 0) {
338
+ lines.push(`### Style changes (${styleFiles.length} files) — run infernoflow_scan_ui to check UI contract`);
339
+ styleFiles.slice(0, 5).forEach(f => lines.push(` - ${f}`));
340
+ lines.push("");
341
+ }
342
+
343
+ if (riskLevel === "LOW" && !newCapSignals.length) {
344
+ lines.push("✔ No new capability signals detected. Safe to merge (run infernoflow_check as final gate).");
345
+ }
346
+
347
+ return lines.join("\n");
348
+ }
349
+
350
+ function buildImplementPrompt(task, mode) {
351
+ const cwd = process.cwd();
352
+ const infernoDir = path.join(cwd, "inferno");
353
+ const contractPath = path.join(infernoDir, "contract.json");
354
+ const capsPath = path.join(infernoDir, "capabilities.json");
355
+ const profilePath = path.join(infernoDir, "developer-profile.json");
356
+
357
+ if (!fs.existsSync(contractPath)) return "inferno/ not found — run infernoflow init first";
358
+
359
+ const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
360
+ const caps = fs.existsSync(capsPath) ? JSON.parse(fs.readFileSync(capsPath, "utf8")) : {};
361
+ const profile = fs.existsSync(profilePath) ? JSON.parse(fs.readFileSync(profilePath, "utf8")) : {};
362
+
363
+ const capList = (caps.capabilities || []).map(c => ` - ${c.id}: ${c.title || c.id}`).join("\n");
364
+ const stack = profile.stack || {};
365
+ const stackLine = [stack.framework, stack.language, stack.projectType].filter(Boolean).join(" / ") || "unknown";
366
+ const namingStyle = profile.namingStyle || "PascalCase";
367
+
368
+ const cursorPrompt = `## Cursor Agent Implementation Prompt
369
+ Task: "${task}"
370
+ Project: ${contract.policyId} (${stackLine})
371
+ Naming convention: ${namingStyle}
372
+
373
+ ### Current capabilities
374
+ ${capList || " (none registered)"}
375
+
376
+ ### Implementation instructions
377
+ 1. Implement "${task}" following the existing code patterns in this project
378
+ 2. Use ${namingStyle} for any new identifiers, matching the existing capability naming
379
+ 3. Keep changes minimal — only touch files relevant to this task
380
+ 4. After implementing, call \`infernoflow_run\` with task "${task}" to update the contract
381
+ 5. Then call \`infernoflow_check\` to validate everything is in sync
382
+
383
+ ### Definition of done
384
+ - Feature works as described
385
+ - Contract updated via infernoflow_run → infernoflow_apply
386
+ - infernoflow_check passes`;
387
+
388
+ const genericPrompt = `## Implementation Prompt
389
+ Task: "${task}"
390
+ Project: ${contract.policyId}
391
+ Stack: ${stackLine}
392
+ Capabilities already in contract: ${(contract.capabilities || []).join(", ")}
393
+
394
+ Implement the task above. When done, run:
395
+ infernoflow suggest "${task}"
396
+ infernoflow check`;
397
+
398
+ if (mode === "cursor") return cursorPrompt;
399
+ if (mode === "generic") return genericPrompt;
400
+ return cursorPrompt + "\n\n---\n\n" + genericPrompt;
401
+ }
402
+
403
+ function buildPrompt(task) {
404
+ const infernoDir = path.join(process.cwd(), "inferno");
405
+ const contractPath = path.join(infernoDir, "contract.json");
406
+ const capsPath = path.join(infernoDir, "capabilities.json");
407
+ if (!fs.existsSync(contractPath)) return null;
408
+ const contract = JSON.parse(fs.readFileSync(contractPath, "utf8"));
409
+ const caps = fs.existsSync(capsPath) ? JSON.parse(fs.readFileSync(capsPath, "utf8")) : {};
410
+ const capList = (caps.capabilities || []).map(c => ` - ${c.id}: ${c.title || c.id}`).join("\n");
411
+ return `You are a developer assistant for the infernoflow CLI tool.
412
+ Analyze this task and suggest updates to the infernoflow contract files.
413
+
414
+ ## Current contract
415
+ policyId: ${contract.policyId}
416
+ policyVersion: ${contract.policyVersion}
417
+ capabilities: [${(contract.capabilities || []).join(", ")}]
418
+
419
+ ## Capabilities registry
420
+ ${capList || " (none)"}
421
+
422
+ ## Task
423
+ "${task}"
424
+
425
+ ## Instructions
426
+ Respond with ONLY a valid JSON object:
427
+ {
428
+ "summary": "one-line summary of what changed",
429
+ "newCapabilities": [{ "id": "PascalCase", "title": "Human readable title", "reason": "why this is new" }],
430
+ "removedCapabilities": [],
431
+ "updatedScenarios": [],
432
+ "changelogEntry": "- Short description for CHANGELOG.md"
433
+ }`;
434
+ }
435
+
436
+ function handleTool(id, name, input) {
437
+ try {
438
+ let text = "";
439
+ if (name === "infernoflow_run") {
440
+ const prompt = buildPrompt(input.task);
441
+ if (!prompt) { sendError(id, -32000, "inferno/ not found — run infernoflow init first"); return; }
442
+ const promptFile = path.join(process.cwd(), "inferno", "agent-prompt.md");
443
+ fs.writeFileSync(promptFile, prompt, "utf8");
444
+ text = `## infernoflow task: "${input.task}"\n\n${prompt}\n\n---\nRespond with the JSON, then call **infernoflow_apply** with your JSON string.`;
445
+ } else if (name === "infernoflow_apply") {
446
+ const responseFile = path.join(process.cwd(), "inferno", "agent-response.json");
447
+ let json = input.json.trim().replace(/^```json?\n?/, "").replace(/\n?```$/, "");
448
+ fs.writeFileSync(responseFile, json, "utf8");
449
+ text = runCmd(`run "apply"`, { INFERNO_AGENT_RESPONSE_FILE: responseFile, INFERNO_AGENT_AVAILABLE: "1" });
450
+ } else if (name === "infernoflow_check") {
451
+ text = runCmd("check");
452
+ } else if (name === "infernoflow_status") {
453
+ text = runCmd("status");
454
+ } else if (name === "infernoflow_context") {
455
+ const parts = [];
456
+ if (input.intent) parts.push(`--intent "${input.intent}"`);
457
+ if (input.working) parts.push(`--working "${input.working}"`);
458
+ text = runCmd("context " + parts.join(" "));
459
+ } else if (name === "infernoflow_git_drift") {
460
+ text = detectGitDrift(input.sinceCommits || 1);
461
+ } else if (name === "infernoflow_implement") {
462
+ text = buildImplementPrompt(input.task, input.mode || "both");
463
+ } else if (name === "infernoflow_scan_ui") {
464
+ text = scanUi();
465
+ } else if (name === "infernoflow_review") {
466
+ text = reviewDrift(input.branch || "main");
467
+
468
+ // ── AMP-spec aliases ───────────────────────────────────────────────────
469
+ } else if (name === "amp_read") {
470
+ const args = [];
471
+ if (input.query) args.push(JSON.stringify(input.query));
472
+ if (input.type) args.push("--type", input.type);
473
+ if (input.limit) args.push("--limit", String(input.limit));
474
+ text = runCmd("ask " + args.join(" "));
475
+ } else if (name === "amp_write") {
476
+ const t = (input.type || "note").replace(/[^a-z]/g, "");
477
+ const m = JSON.stringify(input.msg || "");
478
+ const extras = [];
479
+ if (input.file) extras.push("--source", JSON.stringify(input.file));
480
+ text = runCmd(`log ${m} --type ${t} ${extras.join(" ")}`);
481
+ } else if (name === "amp_handoff") {
482
+ // switch writes a file; we read it back to return the content
483
+ runCmd("switch");
484
+ try {
485
+ const ampPath = path.join(process.cwd(), ".ai-memory", "handoff.md");
486
+ const legacyPath = path.join(process.cwd(), "inferno", "HANDOFF.md");
487
+ const target = fs.existsSync(ampPath) ? ampPath : legacyPath;
488
+ text = fs.readFileSync(target, "utf8");
489
+ if (input.format === "json") {
490
+ // very small markdown-to-json — caller can re-parse if needed
491
+ text = JSON.stringify({ handoff: text });
492
+ }
493
+ } catch (err) {
494
+ text = "(handoff generated; could not read back: " + err.message + ")";
495
+ }
496
+ } else if (name === "amp_search") {
497
+ const args = [JSON.stringify(input.query || "")];
498
+ if (input.type) args.push("--type", input.type);
499
+ text = runCmd("ask " + args.join(" "));
500
+ } else if (name === "amp_health") {
501
+ text = runCmd("recap --json").trim() || runCmd("status");
502
+
503
+ } else { return sendError(id, -32601, `Unknown tool: ${name}`); }
504
+ sendResult(id, { content: [{ type: "text", text: text || "(no output)" }] });
505
+ } catch (err) { sendError(id, -32000, err.message); }
506
+ }
507
+
508
+ const rl = readline.createInterface({ input: process.stdin });
509
+ rl.on("line", (line) => {
510
+ let msg; try { msg = JSON.parse(line); } catch { return; }
511
+ const { id, method, params } = msg;
512
+ if (method === "initialize") { sendResult(id, { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "infernoflow", version: "1.0.0" } }); return; }
513
+ if (method === "tools/list") { sendResult(id, { tools: TOOLS }); return; }
514
+ if (method === "tools/call") { handleTool(id, params.name, params.arguments || {}); return; }
515
+ if (id !== undefined) sendError(id, -32601, `Method not found: ${method}`);
516
+ });
471
517
  process.stderr.write("[infernoflow MCP] started\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infernoflow",
3
- "version": "0.42.1",
3
+ "version": "0.42.4",
4
4
  "description": "Persistent memory for AI coding sessions \u2014 captures what agents can't infer from code alone. Works with Copilot, Cursor, Claude, and Windsurf.",
5
5
  "type": "module",
6
6
  "bin": {