framein 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/anomaly.js CHANGED
@@ -1,39 +1,39 @@
1
- // Audit cadence (ADR-0005, F-AUDIT-3): detect "thrash" signals from the task ledger so a
2
- // reviewer can be pulled in only when an agent is going in circles — not on every turn.
3
- // Pure function over ledger entries; thresholds are tunable (PRD §11.8).
4
- export function detectThrash(entries, opts = {}) {
5
- const editThreshold = opts.repeatedEdits ?? 3;
6
- const failThreshold = opts.repeatedFailures ?? 2;
7
- const noProgress = opts.noProgressTurns ?? 5;
8
- const signals = [];
9
- const editCounts = new Map();
10
- const failCounts = new Map();
11
- for (const e of entries) {
12
- if (e.kind === 'edit' && e.target)
13
- editCounts.set(e.target, (editCounts.get(e.target) ?? 0) + 1);
14
- if (e.kind === 'test-fail' && e.target)
15
- failCounts.set(e.target, (failCounts.get(e.target) ?? 0) + 1);
16
- }
17
- for (const [target, count] of editCounts) {
18
- if (count >= editThreshold)
19
- signals.push({ kind: 'repeated-edits', target, count, message: `'${target}' edited ${count}× — possible thrash loop` });
20
- }
21
- for (const [target, count] of failCounts) {
22
- if (count >= failThreshold)
23
- signals.push({ kind: 'repeated-failure', target, count, message: `'${target}' failed ${count}× — stuck on the same test` });
24
- }
25
- // turns accumulated since the last real progress (edit/commit). Other events (ask,
26
- // test-fail) are neither progress nor turns — they're skipped, not counted.
27
- let trailingTurns = 0;
28
- for (let i = entries.length - 1; i >= 0; i--) {
29
- const k = entries[i].kind;
30
- if (k === 'edit' || k === 'commit')
31
- break;
32
- if (k === 'turn')
33
- trailingTurns++;
34
- }
35
- if (trailingTurns >= noProgress) {
36
- signals.push({ kind: 'no-progress', count: trailingTurns, message: `${trailingTurns} turns without an edit/commit — may be going in circles` });
37
- }
38
- return signals;
39
- }
1
+ // Audit cadence (ADR-0005, F-AUDIT-3): detect "thrash" signals from the task ledger so a
2
+ // reviewer can be pulled in only when an agent is going in circles — not on every turn.
3
+ // Pure function over ledger entries; thresholds are tunable (PRD §11.8).
4
+ export function detectThrash(entries, opts = {}) {
5
+ const editThreshold = opts.repeatedEdits ?? 3;
6
+ const failThreshold = opts.repeatedFailures ?? 2;
7
+ const noProgress = opts.noProgressTurns ?? 5;
8
+ const signals = [];
9
+ const editCounts = new Map();
10
+ const failCounts = new Map();
11
+ for (const e of entries) {
12
+ if (e.kind === 'edit' && e.target)
13
+ editCounts.set(e.target, (editCounts.get(e.target) ?? 0) + 1);
14
+ if (e.kind === 'test-fail' && e.target)
15
+ failCounts.set(e.target, (failCounts.get(e.target) ?? 0) + 1);
16
+ }
17
+ for (const [target, count] of editCounts) {
18
+ if (count >= editThreshold)
19
+ signals.push({ kind: 'repeated-edits', target, count, message: `'${target}' edited ${count}× — possible thrash loop` });
20
+ }
21
+ for (const [target, count] of failCounts) {
22
+ if (count >= failThreshold)
23
+ signals.push({ kind: 'repeated-failure', target, count, message: `'${target}' failed ${count}× — stuck on the same test` });
24
+ }
25
+ // turns accumulated since the last real progress (edit/commit). Other events (ask,
26
+ // test-fail) are neither progress nor turns — they're skipped, not counted.
27
+ let trailingTurns = 0;
28
+ for (let i = entries.length - 1; i >= 0; i--) {
29
+ const k = entries[i].kind;
30
+ if (k === 'edit' || k === 'commit')
31
+ break;
32
+ if (k === 'turn')
33
+ trailingTurns++;
34
+ }
35
+ if (trailingTurns >= noProgress) {
36
+ signals.push({ kind: 'no-progress', count: trailingTurns, message: `${trailingTurns} turns without an edit/commit — may be going in circles` });
37
+ }
38
+ return signals;
39
+ }
package/dist/bin.js CHANGED
@@ -1,27 +1,27 @@
1
- #!/usr/bin/env node
2
- // framein installed-bin entry. The node:sqlite ExperimentalWarning (and any other Node warning) is
3
- // printed at module-LOAD time, before any in-process filter can run — the only reliable suppression is
4
- // Node's own `--no-warnings`. So this tiny entry, which imports NOTHING that loads node:sqlite, re-execs
5
- // the CLI once under `--no-warnings`. stdio:'inherit' keeps stdin/stdout/stderr byte-exact (MCP serve
6
- // NDJSON, `ask --interactive` / shell `/go` hand-overs all pass straight through) and the child's exit
7
- // code is propagated. FRAMEIN_NOWARN guards against a re-exec loop; running `node dist/cli.js` directly
8
- // (dev/tests) bypasses this entirely.
9
- //
10
- // EXCEPTION: `mcp serve` is machine-facing — MCP clients read NDJSON on stdout and ignore stderr, so the
11
- // SQLite warning is harmless there. We skip the re-exec for it to avoid adding any startup latency to the
12
- // server an agent just spawned (a slow handshake can make a client cancel the first tool call).
13
- import { spawnSync } from 'node:child_process';
14
- const argv = process.argv.slice(2);
15
- const isMcpServe = argv[0] === 'mcp' && argv[1] === 'serve';
16
- if (process.env.FRAMEIN_NOWARN === undefined && !isMcpServe && typeof process.argv[1] === 'string') {
17
- const res = spawnSync(process.execPath, ['--no-warnings', process.argv[1], ...process.argv.slice(2)], {
18
- stdio: 'inherit',
19
- env: { ...process.env, FRAMEIN_NOWARN: '1' },
20
- });
21
- if (res.error) {
22
- console.error(res.error.message);
23
- process.exit(1);
24
- }
25
- process.exit(res.status ?? 1);
26
- }
27
- await import('./cli.js');
1
+ #!/usr/bin/env node
2
+ // framein installed-bin entry. The node:sqlite ExperimentalWarning (and any other Node warning) is
3
+ // printed at module-LOAD time, before any in-process filter can run — the only reliable suppression is
4
+ // Node's own `--no-warnings`. So this tiny entry, which imports NOTHING that loads node:sqlite, re-execs
5
+ // the CLI once under `--no-warnings`. stdio:'inherit' keeps stdin/stdout/stderr byte-exact (MCP serve
6
+ // NDJSON, `ask --interactive` / shell `/go` hand-overs all pass straight through) and the child's exit
7
+ // code is propagated. FRAMEIN_NOWARN guards against a re-exec loop; running `node dist/cli.js` directly
8
+ // (dev/tests) bypasses this entirely.
9
+ //
10
+ // EXCEPTION: `mcp serve` is machine-facing — MCP clients read NDJSON on stdout and ignore stderr, so the
11
+ // SQLite warning is harmless there. We skip the re-exec for it to avoid adding any startup latency to the
12
+ // server an agent just spawned (a slow handshake can make a client cancel the first tool call).
13
+ import { spawnSync } from 'node:child_process';
14
+ const argv = process.argv.slice(2);
15
+ const isMcpServe = argv[0] === 'mcp' && argv[1] === 'serve';
16
+ if (process.env.FRAMEIN_NOWARN === undefined && !isMcpServe && typeof process.argv[1] === 'string') {
17
+ const res = spawnSync(process.execPath, ['--no-warnings', process.argv[1], ...process.argv.slice(2)], {
18
+ stdio: 'inherit',
19
+ env: { ...process.env, FRAMEIN_NOWARN: '1' },
20
+ });
21
+ if (res.error) {
22
+ console.error(res.error.message);
23
+ process.exit(1);
24
+ }
25
+ process.exit(res.status ?? 1);
26
+ }
27
+ await import('./cli.js');
package/dist/blast.js CHANGED
@@ -1,51 +1,51 @@
1
- // Blast Radius Guard (F-LOOP-6, ADR-0008): detect when a change touches sensitive code and raise
2
- // the required gates — but only when risk actually changes, matching the audit cadence (ADR-0005:
3
- // not every task). Pure: map changed file paths to a risk level + required gates. Reading the
4
- // changed files (git) and acting on the gate live in cli.ts.
5
- import { PLAIN } from './ui/theme.js';
6
- // Order matters only for readability; each file is matched against every rule.
7
- const RULES = [
8
- { category: 'secrets', level: 'high', pattern: /(^|\/)\.env(\.|$)|secret|credential|\.pem$|\.key$/i, gate: 'secret scan / rotation validation' },
9
- { category: 'auth', level: 'high', pattern: /auth|login|session|oauth|permission|rbac|password/i, gate: 'security review' },
10
- { category: 'payment', level: 'high', pattern: /payment|billing|stripe|checkout|invoice|charge/i, gate: 'security review (payments)' },
11
- { category: 'migration', level: 'high', pattern: /migrat|\.sql$|schema\.|prisma\/migrations|alembic/i, gate: 'migration rollback validation' },
12
- { category: 'deploy', level: 'high', pattern: /dockerfile|docker-compose|\.tf$|terraform|fly\.toml|vercel\.json|(^|\/)k8s\/|\.github\/workflows/i, gate: 'deploy rollback plan' },
13
- { category: 'deps', level: 'medium', pattern: /(^|\/)package\.json$|package-lock\.json|yarn\.lock|pnpm-lock\.yaml/i, gate: 'dependency justification' },
14
- { category: 'config', level: 'medium', pattern: /(^|\/)config\/|\.env\.example$|settings\.(json|py|ts)|\.config\./i, gate: 'config review' },
15
- ];
16
- const RANK = { low: 0, medium: 1, high: 2 };
17
- export function riskRank(level) { return RANK[level]; }
18
- export function assessBlastRadius(changedFiles) {
19
- const hits = [];
20
- const gates = new Set();
21
- let level = 'low';
22
- for (const file of changedFiles) {
23
- for (const rule of RULES) {
24
- if (rule.pattern.test(file)) {
25
- hits.push({ category: rule.category, file });
26
- gates.add(rule.gate);
27
- if (RANK[rule.level] > RANK[level])
28
- level = rule.level;
29
- }
30
- }
31
- }
32
- return { level, hits, requiredGates: [...gates] };
33
- }
34
- /** A message when risk INCREASED vs the previous assessment (cadence: only speak on change). */
35
- export function riskTransition(prev, curr) {
36
- if (prev === undefined || RANK[curr] <= RANK[prev])
37
- return undefined;
38
- return `Risk level changed: ${prev.toUpperCase()} → ${curr.toUpperCase()}`;
39
- }
40
- export function renderBlast(a, ui = PLAIN) {
41
- if (a.level === 'low')
42
- return `Risk level: ${ui.tone('LOW', 'success')} (no sensitive files touched)`;
43
- const tone = a.level === 'high' ? 'danger' : 'warning';
44
- const lines = [`Risk level: ${ui.tone(a.level.toUpperCase(), tone)}`, 'Reason:'];
45
- for (const h of a.hits)
46
- lines.push(` - ${h.category}: ${h.file}`);
47
- lines.push('Required before ship:');
48
- for (const g of a.requiredGates)
49
- lines.push(` - ${g}`);
50
- return lines.join('\n');
51
- }
1
+ // Blast Radius Guard (F-LOOP-6, ADR-0008): detect when a change touches sensitive code and raise
2
+ // the required gates — but only when risk actually changes, matching the audit cadence (ADR-0005:
3
+ // not every task). Pure: map changed file paths to a risk level + required gates. Reading the
4
+ // changed files (git) and acting on the gate live in cli.ts.
5
+ import { PLAIN } from './ui/theme.js';
6
+ // Order matters only for readability; each file is matched against every rule.
7
+ const RULES = [
8
+ { category: 'secrets', level: 'high', pattern: /(^|\/)\.env(\.|$)|secret|credential|\.pem$|\.key$/i, gate: 'secret scan / rotation validation' },
9
+ { category: 'auth', level: 'high', pattern: /auth|login|session|oauth|permission|rbac|password/i, gate: 'security review' },
10
+ { category: 'payment', level: 'high', pattern: /payment|billing|stripe|checkout|invoice|charge/i, gate: 'security review (payments)' },
11
+ { category: 'migration', level: 'high', pattern: /migrat|\.sql$|schema\.|prisma\/migrations|alembic/i, gate: 'migration rollback validation' },
12
+ { category: 'deploy', level: 'high', pattern: /dockerfile|docker-compose|\.tf$|terraform|fly\.toml|vercel\.json|(^|\/)k8s\/|\.github\/workflows/i, gate: 'deploy rollback plan' },
13
+ { category: 'deps', level: 'medium', pattern: /(^|\/)package\.json$|package-lock\.json|yarn\.lock|pnpm-lock\.yaml/i, gate: 'dependency justification' },
14
+ { category: 'config', level: 'medium', pattern: /(^|\/)config\/|\.env\.example$|settings\.(json|py|ts)|\.config\./i, gate: 'config review' },
15
+ ];
16
+ const RANK = { low: 0, medium: 1, high: 2 };
17
+ export function riskRank(level) { return RANK[level]; }
18
+ export function assessBlastRadius(changedFiles) {
19
+ const hits = [];
20
+ const gates = new Set();
21
+ let level = 'low';
22
+ for (const file of changedFiles) {
23
+ for (const rule of RULES) {
24
+ if (rule.pattern.test(file)) {
25
+ hits.push({ category: rule.category, file });
26
+ gates.add(rule.gate);
27
+ if (RANK[rule.level] > RANK[level])
28
+ level = rule.level;
29
+ }
30
+ }
31
+ }
32
+ return { level, hits, requiredGates: [...gates] };
33
+ }
34
+ /** A message when risk INCREASED vs the previous assessment (cadence: only speak on change). */
35
+ export function riskTransition(prev, curr) {
36
+ if (prev === undefined || RANK[curr] <= RANK[prev])
37
+ return undefined;
38
+ return `Risk level changed: ${prev.toUpperCase()} → ${curr.toUpperCase()}`;
39
+ }
40
+ export function renderBlast(a, ui = PLAIN) {
41
+ if (a.level === 'low')
42
+ return `Risk level: ${ui.tone('LOW', 'success')} (no sensitive files touched)`;
43
+ const tone = a.level === 'high' ? 'danger' : 'warning';
44
+ const lines = [`Risk level: ${ui.tone(a.level.toUpperCase(), tone)}`, 'Reason:'];
45
+ for (const h of a.hits)
46
+ lines.push(` - ${h.category}: ${h.file}`);
47
+ lines.push('Required before ship:');
48
+ for (const g of a.requiredGates)
49
+ lines.push(` - ${g}`);
50
+ return lines.join('\n');
51
+ }
package/dist/brief.js CHANGED
@@ -1,21 +1,21 @@
1
- // Ownership Brief (F-LOOP-10, ADR-0008): make the explainer produce a doc the user can take
2
- // OWNERSHIP of — not just a friendly recap. Pure: render the brief skeleton, filling the facts
3
- // framein already knows (changed files, how to test, how to roll back) and leaving the narrative
4
- // sections for the live explainer role. Gathering the facts lives in cli.ts.
5
- const TBD = ' (for the explainer role to fill)';
6
- export function ownershipBrief(input) {
7
- const changed = input.changedFiles?.length
8
- ? input.changedFiles.map((f) => ` - ${f}`).join('\n')
9
- : ' (no changed files detected)';
10
- const sections = [
11
- ['What changed', changed],
12
- ['How to test it', input.testCommand ? ` ${input.testCommand}` : ' (no test command found)'],
13
- ['How to roll it back', input.lastGreen ? ` git reset --hard ${input.lastGreen.slice(0, 7)} (last green checkpoint)` : ' (no checkpoint recorded — run `frame checkpoint`)'],
14
- ['How requests flow', TBD],
15
- ['Where configuration lives', TBD],
16
- ['Known limitations', TBD],
17
- ['What will likely break next', TBD],
18
- ];
19
- const head = `Ownership brief${input.goal ? `: ${input.goal}` : ''}`;
20
- return [head, '', ...sections.map(([h, b]) => `## ${h}\n${b}`)].join('\n');
21
- }
1
+ // Ownership Brief (F-LOOP-10, ADR-0008): make the explainer produce a doc the user can take
2
+ // OWNERSHIP of — not just a friendly recap. Pure: render the brief skeleton, filling the facts
3
+ // framein already knows (changed files, how to test, how to roll back) and leaving the narrative
4
+ // sections for the live explainer role. Gathering the facts lives in cli.ts.
5
+ const TBD = ' (for the explainer role to fill)';
6
+ export function ownershipBrief(input) {
7
+ const changed = input.changedFiles?.length
8
+ ? input.changedFiles.map((f) => ` - ${f}`).join('\n')
9
+ : ' (no changed files detected)';
10
+ const sections = [
11
+ ['What changed', changed],
12
+ ['How to test it', input.testCommand ? ` ${input.testCommand}` : ' (no test command found)'],
13
+ ['How to roll it back', input.lastGreen ? ` git reset --hard ${input.lastGreen.slice(0, 7)} (last green checkpoint)` : ' (no checkpoint recorded — run `frame checkpoint`)'],
14
+ ['How requests flow', TBD],
15
+ ['Where configuration lives', TBD],
16
+ ['Known limitations', TBD],
17
+ ['What will likely break next', TBD],
18
+ ];
19
+ const head = `Ownership brief${input.goal ? `: ${input.goal}` : ''}`;
20
+ return [head, '', ...sections.map(([h, b]) => `## ${h}\n${b}`)].join('\n');
21
+ }
package/dist/cli.js CHANGED
@@ -1545,7 +1545,7 @@ function cmdShell() {
1545
1545
  // below it and ERASES itself on choice (clearOnExit), so what remains is a single lobby screen —
1546
1546
  // not a stacked [pick]+[welcome] two-stage view.
1547
1547
  if (interactive) {
1548
- const ver = readVersion().replace(/^framein /, 'v'); // e.g. v0.0.5
1548
+ const ver = readVersion().replace(/^framein /, 'v'); // e.g. v0.0.6
1549
1549
  console.log(renderFrame('FRAMEIN', [`Framein by Frameout · ${ver}`, 'Intent in · Validation in · Drift out'], { ui, unicode: caps.unicode, columns: caps.columns }));
1550
1550
  console.log('');
1551
1551
  }
package/dist/db.js CHANGED
@@ -1,7 +1,7 @@
1
- // Thin typed facade over the experimental built-in node:sqlite.
2
- // Declaring our own minimal surface decouples us from @types/node version drift.
3
- // @ts-ignore - node:sqlite is experimental; type defs may be absent.
4
- import { DatabaseSync } from 'node:sqlite';
5
- export function openDb(path) {
6
- return new DatabaseSync(path);
7
- }
1
+ // Thin typed facade over the experimental built-in node:sqlite.
2
+ // Declaring our own minimal surface decouples us from @types/node version drift.
3
+ // @ts-ignore - node:sqlite is experimental; type defs may be absent.
4
+ import { DatabaseSync } from 'node:sqlite';
5
+ export function openDb(path) {
6
+ return new DatabaseSync(path);
7
+ }
package/dist/debt.js CHANGED
@@ -1,42 +1,42 @@
1
- // Vibe Debt Delta (F-LOOP-9, ADR-0008): show the debt THIS change added — not the codebase's
2
- // hundreds of pre-existing warnings. Pure: parse a unified git diff into a small delta. Heuristic
3
- // by design (a hint, not a linter); reading the diff (git) lives in cli.ts.
4
- import { PLAIN } from './ui/theme.js';
5
- export function parseDiffDebt(diff) {
6
- let addedLines = 0, removedLines = 0, todos = 0;
7
- const addedDeps = [];
8
- let curFile = '';
9
- for (const line of (diff ?? '').split('\n')) {
10
- if (line.startsWith('+++ ')) {
11
- curFile = line.replace(/^\+\+\+ (b\/)?/, '').trim();
12
- continue;
13
- }
14
- if (line.startsWith('--- ') || line.startsWith('@@') || line.startsWith('diff '))
15
- continue;
16
- if (line.startsWith('+')) {
17
- addedLines++;
18
- if (/\b(TODO|FIXME|HACK|XXX)\b/.test(line))
19
- todos++;
20
- if (/package\.json$/.test(curFile)) {
21
- const m = line.match(/^\+\s*"([^"]+)":\s*"[~^]?\d/); // "pkg": "^1.2.3" style additions
22
- if (m)
23
- addedDeps.push(m[1]);
24
- }
25
- }
26
- else if (line.startsWith('-')) {
27
- removedLines++;
28
- }
29
- }
30
- return { addedLines, removedLines, addedDeps, todos };
31
- }
32
- export function renderDebt(d, ui = PLAIN) {
33
- const lines = [ui.tone('Debt delta (this change only):', 'muted')];
34
- if (d.addedDeps.length)
35
- lines.push(ui.tone(` + ${d.addedDeps.length} runtime dependency${d.addedDeps.length > 1 ? '(ies)' : ''}: ${d.addedDeps.join(', ')}`, 'warning'));
36
- if (d.todos)
37
- lines.push(ui.tone(` + ${d.todos} TODO/FIXME`, 'warning'));
38
- lines.push(` ~ ${d.addedLines} added / ${d.removedLines} removed lines`);
39
- if (!d.addedDeps.length && !d.todos)
40
- lines.push(ui.tone(' (no new deps or TODOs)', 'success'));
41
- return lines.join('\n');
42
- }
1
+ // Vibe Debt Delta (F-LOOP-9, ADR-0008): show the debt THIS change added — not the codebase's
2
+ // hundreds of pre-existing warnings. Pure: parse a unified git diff into a small delta. Heuristic
3
+ // by design (a hint, not a linter); reading the diff (git) lives in cli.ts.
4
+ import { PLAIN } from './ui/theme.js';
5
+ export function parseDiffDebt(diff) {
6
+ let addedLines = 0, removedLines = 0, todos = 0;
7
+ const addedDeps = [];
8
+ let curFile = '';
9
+ for (const line of (diff ?? '').split('\n')) {
10
+ if (line.startsWith('+++ ')) {
11
+ curFile = line.replace(/^\+\+\+ (b\/)?/, '').trim();
12
+ continue;
13
+ }
14
+ if (line.startsWith('--- ') || line.startsWith('@@') || line.startsWith('diff '))
15
+ continue;
16
+ if (line.startsWith('+')) {
17
+ addedLines++;
18
+ if (/\b(TODO|FIXME|HACK|XXX)\b/.test(line))
19
+ todos++;
20
+ if (/package\.json$/.test(curFile)) {
21
+ const m = line.match(/^\+\s*"([^"]+)":\s*"[~^]?\d/); // "pkg": "^1.2.3" style additions
22
+ if (m)
23
+ addedDeps.push(m[1]);
24
+ }
25
+ }
26
+ else if (line.startsWith('-')) {
27
+ removedLines++;
28
+ }
29
+ }
30
+ return { addedLines, removedLines, addedDeps, todos };
31
+ }
32
+ export function renderDebt(d, ui = PLAIN) {
33
+ const lines = [ui.tone('Debt delta (this change only):', 'muted')];
34
+ if (d.addedDeps.length)
35
+ lines.push(ui.tone(` + ${d.addedDeps.length} runtime dependency${d.addedDeps.length > 1 ? '(ies)' : ''}: ${d.addedDeps.join(', ')}`, 'warning'));
36
+ if (d.todos)
37
+ lines.push(ui.tone(` + ${d.todos} TODO/FIXME`, 'warning'));
38
+ lines.push(` ~ ${d.addedLines} added / ${d.removedLines} removed lines`);
39
+ if (!d.addedDeps.length && !d.todos)
40
+ lines.push(ui.tone(' (no new deps or TODOs)', 'success'));
41
+ return lines.join('\n');
42
+ }
package/dist/detect.js CHANGED
@@ -1,118 +1,118 @@
1
- // Reuse-first (ADR-0002/0004): DETECT each agent's existing MCP servers and skills and
2
- // surface them; never proxy or reimplement them. Parsers are pure (fixture-testable);
3
- // the disk layer is best-effort and swallows missing/malformed files.
4
- import { existsSync, readFileSync, readdirSync } from 'node:fs';
5
- import { homedir } from 'node:os';
6
- import { join } from 'node:path';
7
- // --- pure parsers (one per CLI's config format) ---
8
- /** Claude project `.mcp.json`: { "mcpServers": { name: { command, args } } } */
9
- export function parseClaudeMcpJson(text) {
10
- const servers = (JSON.parse(text)?.mcpServers ?? {});
11
- return Object.entries(servers).map(([name, def]) => ({ agent: 'claude', name, command: def?.command ?? '' }));
12
- }
13
- /** Gemini `settings.json`: { "mcpServers": { name: { command, args } } } */
14
- export function parseGeminiMcpJson(text) {
15
- const servers = (JSON.parse(text)?.mcpServers ?? {});
16
- return Object.entries(servers).map(([name, def]) => ({ agent: 'gemini', name, command: def?.command ?? '' }));
17
- }
18
- /**
19
- * Codex `~/.codex/config.toml`: top-level `[mcp_servers.<name>]` tables (sub-tables like
20
- * `[mcp_servers.<name>.env]` are NOT servers). Minimal line-based reader (zero-dep).
21
- */
22
- export function parseCodexMcpToml(text) {
23
- const out = [];
24
- let cur = null;
25
- for (const raw of text.split('\n')) {
26
- // strip a trailing ` # comment` (best effort: not quote-aware, but tolerates the common case)
27
- const line = raw.replace(/\s+#.*$/, '').trim();
28
- const sec = line.match(/^\[mcp_servers\.([^.\]]+)\]$/);
29
- if (sec) {
30
- cur = { agent: 'codex', name: sec[1], command: '' };
31
- out.push(cur);
32
- continue;
33
- }
34
- if (line.startsWith('[')) {
35
- cur = null;
36
- continue;
37
- } // any other section ends the current server
38
- if (cur) {
39
- const m = line.match(/^command\s*=\s*['"](.*?)['"]\s*$/);
40
- if (m)
41
- cur.command = m[1];
42
- }
43
- }
44
- return out;
45
- }
46
- /** Server names configured for more than one agent (surfaced, not auto-resolved). */
47
- export function findConflicts(servers) {
48
- const byName = new Map();
49
- for (const s of servers) {
50
- if (!byName.has(s.name))
51
- byName.set(s.name, new Set());
52
- byName.get(s.name).add(s.agent);
53
- }
54
- return [...byName.entries()].filter(([, agents]) => agents.size > 1).map(([name]) => name).sort();
55
- }
56
- /**
57
- * The config patches that would register framein's OWN MCP server into each CLI. Generated
58
- * for the user to apply after approval (§6.3) — framein does not write them automatically.
59
- */
60
- export function frameinMcpRegistration(command = 'framein', args = ['mcp', 'serve']) {
61
- const json = JSON.stringify({ mcpServers: { framein: { command, args } } }, null, 2);
62
- const codex = `[mcp_servers.framein]\ncommand = ${JSON.stringify(command)}\nargs = [${args.map((a) => JSON.stringify(a)).join(', ')}]`;
63
- return { claude: json, codex, gemini: json };
64
- }
65
- export const FRAMEIN_SKILLS = [
66
- { source: 'framein', name: 'adr-flow', description: 'record a decision as an ADR and re-sync all agents' },
67
- { source: 'framein', name: 'delegate', description: 'hand a task to another role; it reads the shared store' },
68
- { source: 'framein', name: 'cross-review', description: 'ask the reviewer role to audit the current change' },
69
- ];
70
- /** Parse the `name`/`description` from a SKILL.md YAML-ish frontmatter block. */
71
- export function parseSkillFrontmatter(md) {
72
- const fm = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
73
- if (!fm)
74
- return {};
75
- const name = fm[1].match(/^name:\s*(.+)$/m)?.[1]?.trim();
76
- const description = fm[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
77
- return { name, description };
78
- }
79
- // --- best-effort disk layer ---
80
- function tryParse(path, parse, into) {
81
- try {
82
- if (existsSync(path))
83
- into.push(...parse(readFileSync(path, 'utf8')));
84
- }
85
- catch { /* ignore malformed/missing */ }
86
- }
87
- export function detectMcpFromDisk(opts = {}) {
88
- const cwd = opts.cwd ?? process.cwd();
89
- const home = opts.home ?? homedir();
90
- const servers = [];
91
- tryParse(join(cwd, '.mcp.json'), parseClaudeMcpJson, servers);
92
- tryParse(join(home, '.codex', 'config.toml'), parseCodexMcpToml, servers);
93
- tryParse(join(home, '.gemini', 'settings.json'), parseGeminiMcpJson, servers);
94
- tryParse(join(cwd, '.gemini', 'settings.json'), parseGeminiMcpJson, servers);
95
- return servers;
96
- }
97
- export function detectSkillsFromDisk(opts = {}) {
98
- const cwd = opts.cwd ?? process.cwd();
99
- const home = opts.home ?? homedir();
100
- const out = [];
101
- for (const base of [join(cwd, '.claude', 'skills'), join(home, '.claude', 'skills')]) {
102
- try {
103
- if (!existsSync(base))
104
- continue;
105
- for (const entry of readdirSync(base, { withFileTypes: true })) {
106
- if (!entry.isDirectory())
107
- continue;
108
- const md = join(base, entry.name, 'SKILL.md');
109
- if (!existsSync(md))
110
- continue;
111
- const { name, description } = parseSkillFrontmatter(readFileSync(md, 'utf8'));
112
- out.push({ source: 'claude', name: name ?? entry.name, description: description ?? '' });
113
- }
114
- }
115
- catch { /* ignore */ }
116
- }
117
- return out;
118
- }
1
+ // Reuse-first (ADR-0002/0004): DETECT each agent's existing MCP servers and skills and
2
+ // surface them; never proxy or reimplement them. Parsers are pure (fixture-testable);
3
+ // the disk layer is best-effort and swallows missing/malformed files.
4
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ // --- pure parsers (one per CLI's config format) ---
8
+ /** Claude project `.mcp.json`: { "mcpServers": { name: { command, args } } } */
9
+ export function parseClaudeMcpJson(text) {
10
+ const servers = (JSON.parse(text)?.mcpServers ?? {});
11
+ return Object.entries(servers).map(([name, def]) => ({ agent: 'claude', name, command: def?.command ?? '' }));
12
+ }
13
+ /** Gemini `settings.json`: { "mcpServers": { name: { command, args } } } */
14
+ export function parseGeminiMcpJson(text) {
15
+ const servers = (JSON.parse(text)?.mcpServers ?? {});
16
+ return Object.entries(servers).map(([name, def]) => ({ agent: 'gemini', name, command: def?.command ?? '' }));
17
+ }
18
+ /**
19
+ * Codex `~/.codex/config.toml`: top-level `[mcp_servers.<name>]` tables (sub-tables like
20
+ * `[mcp_servers.<name>.env]` are NOT servers). Minimal line-based reader (zero-dep).
21
+ */
22
+ export function parseCodexMcpToml(text) {
23
+ const out = [];
24
+ let cur = null;
25
+ for (const raw of text.split('\n')) {
26
+ // strip a trailing ` # comment` (best effort: not quote-aware, but tolerates the common case)
27
+ const line = raw.replace(/\s+#.*$/, '').trim();
28
+ const sec = line.match(/^\[mcp_servers\.([^.\]]+)\]$/);
29
+ if (sec) {
30
+ cur = { agent: 'codex', name: sec[1], command: '' };
31
+ out.push(cur);
32
+ continue;
33
+ }
34
+ if (line.startsWith('[')) {
35
+ cur = null;
36
+ continue;
37
+ } // any other section ends the current server
38
+ if (cur) {
39
+ const m = line.match(/^command\s*=\s*['"](.*?)['"]\s*$/);
40
+ if (m)
41
+ cur.command = m[1];
42
+ }
43
+ }
44
+ return out;
45
+ }
46
+ /** Server names configured for more than one agent (surfaced, not auto-resolved). */
47
+ export function findConflicts(servers) {
48
+ const byName = new Map();
49
+ for (const s of servers) {
50
+ if (!byName.has(s.name))
51
+ byName.set(s.name, new Set());
52
+ byName.get(s.name).add(s.agent);
53
+ }
54
+ return [...byName.entries()].filter(([, agents]) => agents.size > 1).map(([name]) => name).sort();
55
+ }
56
+ /**
57
+ * The config patches that would register framein's OWN MCP server into each CLI. Generated
58
+ * for the user to apply after approval (§6.3) — framein does not write them automatically.
59
+ */
60
+ export function frameinMcpRegistration(command = 'framein', args = ['mcp', 'serve']) {
61
+ const json = JSON.stringify({ mcpServers: { framein: { command, args } } }, null, 2);
62
+ const codex = `[mcp_servers.framein]\ncommand = ${JSON.stringify(command)}\nargs = [${args.map((a) => JSON.stringify(a)).join(', ')}]`;
63
+ return { claude: json, codex, gemini: json };
64
+ }
65
+ export const FRAMEIN_SKILLS = [
66
+ { source: 'framein', name: 'adr-flow', description: 'record a decision as an ADR and re-sync all agents' },
67
+ { source: 'framein', name: 'delegate', description: 'hand a task to another role; it reads the shared store' },
68
+ { source: 'framein', name: 'cross-review', description: 'ask the reviewer role to audit the current change' },
69
+ ];
70
+ /** Parse the `name`/`description` from a SKILL.md YAML-ish frontmatter block. */
71
+ export function parseSkillFrontmatter(md) {
72
+ const fm = md.match(/^---\r?\n([\s\S]*?)\r?\n---/);
73
+ if (!fm)
74
+ return {};
75
+ const name = fm[1].match(/^name:\s*(.+)$/m)?.[1]?.trim();
76
+ const description = fm[1].match(/^description:\s*(.+)$/m)?.[1]?.trim();
77
+ return { name, description };
78
+ }
79
+ // --- best-effort disk layer ---
80
+ function tryParse(path, parse, into) {
81
+ try {
82
+ if (existsSync(path))
83
+ into.push(...parse(readFileSync(path, 'utf8')));
84
+ }
85
+ catch { /* ignore malformed/missing */ }
86
+ }
87
+ export function detectMcpFromDisk(opts = {}) {
88
+ const cwd = opts.cwd ?? process.cwd();
89
+ const home = opts.home ?? homedir();
90
+ const servers = [];
91
+ tryParse(join(cwd, '.mcp.json'), parseClaudeMcpJson, servers);
92
+ tryParse(join(home, '.codex', 'config.toml'), parseCodexMcpToml, servers);
93
+ tryParse(join(home, '.gemini', 'settings.json'), parseGeminiMcpJson, servers);
94
+ tryParse(join(cwd, '.gemini', 'settings.json'), parseGeminiMcpJson, servers);
95
+ return servers;
96
+ }
97
+ export function detectSkillsFromDisk(opts = {}) {
98
+ const cwd = opts.cwd ?? process.cwd();
99
+ const home = opts.home ?? homedir();
100
+ const out = [];
101
+ for (const base of [join(cwd, '.claude', 'skills'), join(home, '.claude', 'skills')]) {
102
+ try {
103
+ if (!existsSync(base))
104
+ continue;
105
+ for (const entry of readdirSync(base, { withFileTypes: true })) {
106
+ if (!entry.isDirectory())
107
+ continue;
108
+ const md = join(base, entry.name, 'SKILL.md');
109
+ if (!existsSync(md))
110
+ continue;
111
+ const { name, description } = parseSkillFrontmatter(readFileSync(md, 'utf8'));
112
+ out.push({ source: 'claude', name: name ?? entry.name, description: description ?? '' });
113
+ }
114
+ }
115
+ catch { /* ignore */ }
116
+ }
117
+ return out;
118
+ }