framein 0.0.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/dist/db.js ADDED
@@ -0,0 +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
+ }
package/dist/debt.js ADDED
@@ -0,0 +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
+ }
@@ -0,0 +1,64 @@
1
+ // Headless delegation (ADR-0007 B-2). The PRIMARY way framein pulls another role into a session:
2
+ // drive each CLI's non-interactive print mode over child_process pipes — NO PTY. This sidesteps the
3
+ // Windows ConPTY/node-pty risk and keeps zero runtime deps. Human-in-the-loop attach uses Node's
4
+ // built-in `stdio:'inherit'` (interactiveCommand below) — a programmatic PTY (node-pty) is
5
+ // deliberately not used (ADR-0009). These builders are pure; the spawn lives in cli.ts and is
6
+ // live-verified against real claude/codex/gemini.
7
+ import { DEFAULT_ROLE_PRIORITY } from './roles.js';
8
+ /**
9
+ * Non-interactive one-shot invocation for each agent (prompt via stdin, response on stdout).
10
+ * `opts.trustFlags` (from trustPlan, F-TRUST) are FIXED per-agent permission-bypass flags appended
11
+ * to argv — still no user input there, so it stays shell-safe under shell:true.
12
+ */
13
+ export function buildInvocation(agent, prompt, opts = {}) {
14
+ const trust = opts.trustFlags ?? [];
15
+ switch (agent) {
16
+ case 'claude': return { command: 'claude', args: ['-p', ...trust], stdin: prompt };
17
+ case 'codex': return { command: 'codex', args: ['exec', '--skip-git-repo-check', ...trust], stdin: prompt }; // exec refuses untrusted/non-git dirs otherwise
18
+ // gemini -p takes the prompt as a value and APPENDS stdin; `--prompt=` (empty) + stdin keeps the
19
+ // prompt off argv (shell-safe), `--skip-trust` is required for headless untrusted dirs. Verified.
20
+ case 'gemini': return { command: 'gemini', args: ['--prompt=', '--skip-trust', ...trust], stdin: prompt };
21
+ default: {
22
+ const _exhaustive = agent;
23
+ throw new Error(`unknown agent: ${String(_exhaustive)}`);
24
+ }
25
+ }
26
+ }
27
+ /** Which agent runs a role: the explicit assignment, else the role's default-priority head. */
28
+ export function resolveAgent(roles, role) {
29
+ return roles[role] ?? DEFAULT_ROLE_PRIORITY[role][0];
30
+ }
31
+ /** The fixed shell command (flags only, no user input) that runDelegated runs with shell:true. */
32
+ export function invocationCommand(inv) {
33
+ return [inv.command, ...inv.args].join(' ');
34
+ }
35
+ /**
36
+ * The bare interactive command for an agent — launched with stdio:'inherit' so the human drives the
37
+ * agent's own TUI directly, inside the already-synced frame (ADR-0007 B-2 interactive path, zero-dep).
38
+ * A true programmatic PTY (read/inject/resize the agent's terminal) would need node-pty — a native
39
+ * runtime dependency that breaks framein's zero-dep invariant (ADR-0003) — and framein observes via
40
+ * the store/ledger, not by screen-scraping a TTY, so it is deliberately NOT used.
41
+ */
42
+ // `resume` re-enters the agent's MOST RECENT session in this cwd (handoff continuity, F-CAPSULE/ADR-0009):
43
+ // claude `--continue`, codex `resume --last`, gemini `--resume`. framein decides re-entry from its own
44
+ // ledger (a prior enter/return for this agent) — it never scrapes the printed session id (ADR-0009).
45
+ export function interactiveCommand(agent, resume = false, trustFlags = []) {
46
+ // F-TRUST placement: codex `resume` is a SUBCOMMAND, so top-level bypass flags must come BEFORE it
47
+ // (`codex --full-auto resume --last`); appending after the subcommand makes codex reject them. claude
48
+ // `--continue` and gemini `--resume` are plain flags, so trust flags can follow.
49
+ const t = trustFlags.length ? ` ${trustFlags.join(' ')}` : '';
50
+ switch (agent) {
51
+ case 'claude': return `claude${resume ? ' --continue' : ''}${t}`;
52
+ case 'codex': return `codex${t}${resume ? ' resume --last' : ''}`;
53
+ case 'gemini': return `gemini${resume ? ' --resume' : ''}${t}`;
54
+ default: {
55
+ const _exhaustive = agent;
56
+ throw new Error(`unknown agent: ${String(_exhaustive)}`);
57
+ }
58
+ }
59
+ }
60
+ /** Human-readable preview for `--show`: the fixed command + a peek at the stdin prompt. */
61
+ export function renderInvocation(inv) {
62
+ const peek = inv.stdin.length > 60 ? inv.stdin.slice(0, 60) + '…' : inv.stdin;
63
+ return `${invocationCommand(inv)} ⟵ stdin: ${JSON.stringify(peek)}`;
64
+ }
package/dist/detect.js ADDED
@@ -0,0 +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
+ }
@@ -0,0 +1,85 @@
1
+ // Disagreement Protocol (F-LOOP-5, ADR-0008): bound model-vs-model debate so it converges instead
2
+ // of looping. A proposal is met with at most `maxRounds` blocking challenges; the reviewer returns
3
+ // CLAIMS + a required change (never edits — the lead keeps control); no agreement after the cap
4
+ // escalates to the human with exactly two options. Pure state machine; the model-authored content
5
+ // of each turn is the deferred live path.
6
+ import { PLAIN } from './ui/theme.js';
7
+ export const MAX_ROUNDS = 2;
8
+ export function newDebate(topic, proposal, maxRounds = MAX_ROUNDS) {
9
+ return { topic, entries: [{ kind: 'proposal', proposal }], maxRounds };
10
+ }
11
+ export function challengeCount(d) {
12
+ return d.entries.filter((e) => e.kind === 'challenge').length;
13
+ }
14
+ function leadPosition(d) {
15
+ for (let i = d.entries.length - 1; i >= 0; i--) {
16
+ const e = d.entries[i];
17
+ if (e.kind === 'revision')
18
+ return e.revision.text || d.topic;
19
+ if (e.kind === 'proposal')
20
+ return e.proposal.text;
21
+ }
22
+ return d.topic;
23
+ }
24
+ function reviewerRequirement(d) {
25
+ for (let i = d.entries.length - 1; i >= 0; i--) {
26
+ const e = d.entries[i];
27
+ if (e.kind === 'challenge' && e.challenge.verdict === 'challenge')
28
+ return e.challenge.requiredChange ?? e.challenge.claim;
29
+ }
30
+ return undefined;
31
+ }
32
+ export function debateStatus(d) {
33
+ const last = d.entries[d.entries.length - 1];
34
+ const rounds = challengeCount(d);
35
+ const escalate = () => ({
36
+ state: 'escalate',
37
+ reason: `no agreement after ${rounds} round${rounds === 1 ? '' : 's'} (max ${d.maxRounds})`,
38
+ options: [`A: ${leadPosition(d)}`, `B: ${reviewerRequirement(d) ?? 'reviewer change'}`],
39
+ });
40
+ if (!last || last.kind === 'proposal')
41
+ return { state: 'awaiting-challenge' };
42
+ if (last.kind === 'challenge') {
43
+ if (last.challenge.verdict === 'accept')
44
+ return { state: 'resolved', how: 'accepted-by-reviewer' };
45
+ if (rounds >= d.maxRounds)
46
+ return escalate();
47
+ return { state: 'awaiting-revision', required: last.challenge.requiredChange };
48
+ }
49
+ // last is a revision
50
+ if (last.revision.accepted)
51
+ return { state: 'resolved', how: 'lead-accepted' };
52
+ if (rounds >= d.maxRounds)
53
+ return escalate();
54
+ return { state: 'awaiting-challenge' };
55
+ }
56
+ export function renderDebate(d, ui = PLAIN) {
57
+ const lines = [`Debate: ${d.topic}`, ''];
58
+ for (const e of d.entries) {
59
+ if (e.kind === 'proposal')
60
+ lines.push(`proposal${e.proposal.by ? ` (${e.proposal.by})` : ''}: ${e.proposal.text}`);
61
+ else if (e.kind === 'challenge') {
62
+ const c = e.challenge;
63
+ lines.push(c.verdict === 'accept'
64
+ ? `challenge${c.by ? ` (${c.by})` : ''}: accept`
65
+ : `challenge${c.by ? ` (${c.by})` : ''}: ${c.claim ?? ''}${c.requiredChange ? ` → require: ${c.requiredChange}` : ''}`);
66
+ }
67
+ else {
68
+ lines.push(`revision: ${e.revision.accepted ? 'accepted' : 'rejected'}${e.revision.text ? ` — ${e.revision.text}` : ''}`);
69
+ }
70
+ }
71
+ lines.push('');
72
+ const st = debateStatus(d);
73
+ if (st.state === 'resolved')
74
+ lines.push(ui.tone(`Resolved (${st.how}).`, 'success'));
75
+ else if (st.state === 'escalate') {
76
+ lines.push(ui.tone(`Escalate to human — ${st.reason}:`, 'warning'));
77
+ for (const o of st.options)
78
+ lines.push(` ${o}`);
79
+ }
80
+ else if (st.state === 'awaiting-revision')
81
+ lines.push(ui.tone(`Awaiting lead revision${st.required ? ` (required: ${st.required})` : ''}.`, 'info'));
82
+ else
83
+ lines.push(ui.tone('Awaiting reviewer challenge.', 'info'));
84
+ return lines.join('\n');
85
+ }
@@ -0,0 +1,72 @@
1
+ // Validation Gate (F-LOOP-2, ADR-0008): "done" is a verified check bundle, not a natural-language
2
+ // claim. Pure logic — parse a test runner's output, gate the bundle against the Task Contract, and
3
+ // render the ship summary. Actually RUNNING build/test (local, deterministic) and the reviewer's
4
+ // model call live in cli.ts; only the latter is the deferred live path.
5
+ import { contractIssues } from './task.js';
6
+ import { PLAIN, statusTone } from './ui/theme.js';
7
+ /** Parse pass/fail counts from common runners (node:test "pass N", jest/vitest "N passed"). */
8
+ export function parseTestSummary(output) {
9
+ const t = output ?? '';
10
+ const num = (re) => { const m = t.match(re); return m ? Number(m[1]) : null; };
11
+ const passed = num(/\bpass(?:ed|ing)?\s+(\d+)\b/i) ?? num(/\b(\d+)\s+pass(?:ed|ing)?\b/i);
12
+ const failed = num(/\bfail(?:ed|ing|ures)?\s+(\d+)\b/i) ?? num(/\b(\d+)\s+fail(?:ed|ing|ures)?\b/i);
13
+ if (passed === null && failed === null)
14
+ return null;
15
+ return { passed: passed ?? 0, failed: failed ?? 0 };
16
+ }
17
+ /**
18
+ * Gate the evidence against the contract. Hard checks (build, tests) decide `ready`; the contract's
19
+ * acceptance criteria and unresolved items surface as warnings (they need a reviewer/human, which
20
+ * the gate never auto-claims as verified).
21
+ */
22
+ export function gate(contract, bundle) {
23
+ const checks = [];
24
+ const warnings = [];
25
+ if (bundle.build)
26
+ checks.push({ label: 'Build', ok: bundle.build.exitCode === 0, detail: bundle.build.command });
27
+ if (bundle.tests) {
28
+ const s = bundle.tests.summary;
29
+ const ok = bundle.tests.exitCode === 0 && (!s || s.failed === 0);
30
+ checks.push({ label: 'Tests', ok, detail: s ? `${s.passed} passed, ${s.failed} failed` : `exit ${bundle.tests.exitCode}` });
31
+ }
32
+ if (checks.length === 0)
33
+ warnings.push('no build/test commands found — nothing was actually verified');
34
+ if (contract) {
35
+ for (const issue of contractIssues(contract))
36
+ warnings.push(`contract: ${issue}`);
37
+ if (contract.acceptance.length)
38
+ warnings.push(`${contract.acceptance.length} acceptance criteria need verification (reviewer/human)`);
39
+ }
40
+ else {
41
+ warnings.push('no task contract — run `frame start <goal>` to define "done"');
42
+ }
43
+ for (const u of bundle.unresolved ?? [])
44
+ warnings.push(`unresolved: ${u}`);
45
+ return { ready: checks.length > 0 && checks.every((c) => c.ok), checks, warnings };
46
+ }
47
+ function header(r) {
48
+ if (!r.ready)
49
+ return 'NOT READY';
50
+ return r.warnings.length ? `READY WITH ${r.warnings.length} WARNING${r.warnings.length > 1 ? 'S' : ''}` : 'READY';
51
+ }
52
+ /** Shared gate body: header + checks + warnings (used by `frame verify`). */
53
+ export function renderGate(r, ui = PLAIN) {
54
+ const h = header(r);
55
+ const lines = [ui.tone(h, statusTone(h)), ''];
56
+ for (const c of r.checks) {
57
+ const mark = c.ok ? ui.tone(ui.sym.pass, 'success') : ui.tone(ui.sym.fail, 'danger');
58
+ lines.push(`${mark} ${c.label}${c.detail ? ': ' + c.detail : ''}`);
59
+ }
60
+ for (const w of r.warnings)
61
+ lines.push(`${ui.tone(ui.sym.warn, 'warning')} ${w}`);
62
+ return lines.join('\n');
63
+ }
64
+ /** Gate body + commit/deploy guidance (used by `frame ship`). */
65
+ export function renderShip(r, ui = PLAIN) {
66
+ return [
67
+ renderGate(r, ui),
68
+ '',
69
+ `Safe to commit: ${r.ready ? 'yes' : 'no'}`,
70
+ `Safe to deploy: ${r.ready ? 'requires human confirmation' : 'no'}`,
71
+ ].join('\n');
72
+ }
@@ -0,0 +1,35 @@
1
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { renderManagedBlock } from './projector.js';
4
+ import { upsertManagedBlock } from './managedBlock.js';
5
+ const NATIVE_FILES = [
6
+ ['CLAUDE.md', 'CLAUDE.md'],
7
+ ['AGENTS.md', 'AGENTS.md'],
8
+ ['GEMINI.md', 'GEMINI.md'],
9
+ ];
10
+ /** Compute what each native file WOULD become (managed-block upsert), without writing. */
11
+ export function planNativeFiles(dir, state) {
12
+ const managed = renderManagedBlock(state);
13
+ return NATIVE_FILES.map(([name, title]) => {
14
+ const path = join(dir, name);
15
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
16
+ const content = upsertManagedBlock(existing, title, managed);
17
+ return { path, content, existed: existing != null, changed: existing !== content };
18
+ });
19
+ }
20
+ /**
21
+ * Upsert the managed block into the three native files, preserving any user content
22
+ * outside the framein markers. Only files whose content actually changes are written
23
+ * (no mtime churn / spurious file-watcher events). Returns the written paths.
24
+ */
25
+ export function writeNativeFiles(dir, state) {
26
+ mkdirSync(dir, { recursive: true });
27
+ const written = [];
28
+ for (const p of planNativeFiles(dir, state)) {
29
+ if (p.changed) {
30
+ writeFileSync(p.path, p.content, 'utf8');
31
+ written.push(p.path);
32
+ }
33
+ }
34
+ return written;
35
+ }
package/dist/ingest.js ADDED
@@ -0,0 +1,38 @@
1
+ // Structured ingest (F-LOOP-5, live): pull a JSON object out of a model's free-form reply so its
2
+ // answer becomes structured framein state (e.g. a reviewer verdict -> a Challenge). Models wrap
3
+ // JSON in prose / ```json fences, so scan for the first balanced, string-aware {...} that parses.
4
+ // Pure; the live model call that produces the text lives in cli.ts.
5
+ export function extractJson(text) {
6
+ const s = text ?? '';
7
+ for (let i = s.indexOf('{'); i !== -1; i = s.indexOf('{', i + 1)) {
8
+ let depth = 0, inStr = false, esc = false;
9
+ for (let j = i; j < s.length; j++) {
10
+ const c = s[j];
11
+ if (inStr) {
12
+ if (esc)
13
+ esc = false;
14
+ else if (c === '\\')
15
+ esc = true;
16
+ else if (c === '"')
17
+ inStr = false;
18
+ continue;
19
+ }
20
+ if (c === '"')
21
+ inStr = true;
22
+ else if (c === '{')
23
+ depth++;
24
+ else if (c === '}') {
25
+ if (--depth === 0) {
26
+ try {
27
+ const o = JSON.parse(s.slice(i, j + 1));
28
+ if (o && typeof o === 'object' && !Array.isArray(o))
29
+ return o;
30
+ }
31
+ catch { /* not valid JSON — fall through and try the next `{` */ }
32
+ break;
33
+ }
34
+ }
35
+ }
36
+ }
37
+ return null;
38
+ }
@@ -0,0 +1,101 @@
1
+ // Managed-block upsert: framein owns only the region between its markers.
2
+ // Everything a user writes outside the markers is preserved across re-projection.
3
+ //
4
+ // Robustness (see codex review): markers are matched as EXACT FULL LINES, malformed
5
+ // states (dangling / reversed / duplicate markers) are cleaned to a single canonical
6
+ // block without losing user text, and marker strings that appear inside the core data
7
+ // are defanged so they can never be mistaken for real markers.
8
+ export const MANAGED_BEGIN = '<!-- framein:begin — managed by `frame`; edits inside are overwritten. Source: .frame/store.db -->';
9
+ export const MANAGED_END = '<!-- framein:end -->';
10
+ function isMarker(line, marker) {
11
+ return line.trim() === marker;
12
+ }
13
+ /** Neutralize any core line that IS exactly a marker, so it cannot be parsed as one. */
14
+ function defangMarkerLines(core) {
15
+ return core.split('\n').map((ln) => {
16
+ const t = ln.trim();
17
+ return t === MANAGED_BEGIN || t === MANAGED_END ? ln.replace('<!--', '&lt;!--') : ln;
18
+ }).join('\n');
19
+ }
20
+ /** Wrap the core block in the managed markers. Identical across all three files. */
21
+ export function wrapManaged(coreBlock) {
22
+ return `${MANAGED_BEGIN}\n${defangMarkerLines(coreBlock)}\n${MANAGED_END}`;
23
+ }
24
+ /** All well-formed [begin,end] line-index regions, paired greedily. */
25
+ function findRegions(lines) {
26
+ const regions = [];
27
+ let i = 0;
28
+ while (i < lines.length) {
29
+ if (isMarker(lines[i], MANAGED_BEGIN)) {
30
+ let j = i + 1;
31
+ while (j < lines.length && !isMarker(lines[j], MANAGED_END))
32
+ j++;
33
+ if (j < lines.length) {
34
+ regions.push([i, j]);
35
+ i = j + 1;
36
+ continue;
37
+ }
38
+ }
39
+ i++;
40
+ }
41
+ return regions;
42
+ }
43
+ /** Count of lines that are exactly a begin or end marker. */
44
+ function markerLineCount(lines) {
45
+ return lines.filter((l) => isMarker(l, MANAGED_BEGIN) || isMarker(l, MANAGED_END)).length;
46
+ }
47
+ /** Remove every well-formed managed region and any stray marker lines; preserve the rest. */
48
+ export function stripManagedBlocks(content) {
49
+ const lines = content.split('\n');
50
+ const out = [];
51
+ for (let i = 0; i < lines.length; i++) {
52
+ if (isMarker(lines[i], MANAGED_BEGIN)) {
53
+ let j = i + 1;
54
+ while (j < lines.length && !isMarker(lines[j], MANAGED_END))
55
+ j++;
56
+ if (j < lines.length) {
57
+ i = j;
58
+ continue;
59
+ } // drop a full region
60
+ continue; // dangling begin: drop just this line
61
+ }
62
+ if (isMarker(lines[i], MANAGED_END))
63
+ continue; // stray end: drop just this line
64
+ out.push(lines[i]);
65
+ }
66
+ return out.join('\n');
67
+ }
68
+ /** The single well-formed managed region (markers inclusive), or null. */
69
+ export function extractManagedBlock(content) {
70
+ const lines = content.split('\n');
71
+ const regions = findRegions(lines);
72
+ if (regions.length === 0)
73
+ return null;
74
+ const [b, e] = regions[0];
75
+ return lines.slice(b, e + 1).join('\n');
76
+ }
77
+ /**
78
+ * Insert or replace the managed block in `existing`, preserving everything outside it.
79
+ *
80
+ * - null/empty existing → fresh file: `# <title>` heading + the managed block.
81
+ * - exactly one clean region → replace it IN PLACE (outside bytes preserved exactly).
82
+ * - malformed (dangling/reversed/duplicate/stray markers) → clean all of them and append a
83
+ * single canonical block; subsequent runs see one clean region and are fully idempotent.
84
+ *
85
+ * `managed` must be the output of wrapManaged() (markers included).
86
+ */
87
+ export function upsertManagedBlock(existing, title, managed) {
88
+ if (existing == null || existing.trim() === '') {
89
+ return `# ${title}\n\n${managed}\n`;
90
+ }
91
+ const lines = existing.split('\n');
92
+ const regions = findRegions(lines);
93
+ const healthySingle = regions.length === 1 && markerLineCount(lines) === 2;
94
+ if (healthySingle) {
95
+ const [b, e] = regions[0];
96
+ return [...lines.slice(0, b), ...managed.split('\n'), ...lines.slice(e + 1)].join('\n');
97
+ }
98
+ // malformed or empty-of-user-text: rebuild cleanly without losing content
99
+ const body = stripManagedBlocks(existing).replace(/\s*$/, '');
100
+ return `${body === '' ? `# ${title}` : body}\n\n${managed}\n`;
101
+ }
@@ -0,0 +1,85 @@
1
+ // Apply step of the MCP registration flow (§6.3: detect -> propose -> APPLY approved -> verify).
2
+ // Pure, idempotent config-merge writers that ADD framein's own MCP server into each CLI's config
3
+ // without clobbering anything else (same safety contract as managedBlock, ADR-0007 B-1). The
4
+ // actual file write + live `claude mcp list` spawn live in cli.ts; only the parser below is pure.
5
+ // Zero-dep: JSON via JSON.parse/stringify, TOML via a line-based text merge (no TOML serializer).
6
+ /** framein registers itself as a subprocess MCP server each CLI launches (ADR-0007). */
7
+ export const FRAMEIN_ENTRY = { command: 'framein', args: ['mcp', 'serve'] };
8
+ /**
9
+ * Pick the spawn command the agent CLIs should use to launch framein's MCP server: the canonical
10
+ * `framein` bin when it's on PATH (installed product), else `node <abs cli.js>` (dev / not globally
11
+ * installed). The agent spawns this verbatim, so it MUST resolve to a real executable.
12
+ */
13
+ export function resolveFrameinEntry(frameOnPath, cliPath) {
14
+ return frameOnPath ? { command: 'framein', args: ['mcp', 'serve'] } : { command: 'node', args: [cliPath, 'mcp', 'serve'] };
15
+ }
16
+ /**
17
+ * Merge `mcpServers.<name>` into a JSON config (Claude `.mcp.json`, Gemini `settings.json`),
18
+ * preserving every other key and server. Idempotent. Reformats with 2-space indent.
19
+ */
20
+ export function applyJsonMcp(existing, name = 'framein', entry = FRAMEIN_ENTRY) {
21
+ let root = {};
22
+ if (existing && existing.trim()) {
23
+ const parsed = JSON.parse(existing);
24
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed))
25
+ root = parsed;
26
+ }
27
+ const prev = root.mcpServers;
28
+ const servers = prev && typeof prev === 'object' && !Array.isArray(prev) ? prev : {};
29
+ servers[name] = { command: entry.command, args: entry.args };
30
+ root.mcpServers = servers;
31
+ return JSON.stringify(root, null, 2) + '\n';
32
+ }
33
+ /**
34
+ * Merge a `[mcp_servers.<name>]` table into a Codex `config.toml` as text: replace the existing
35
+ * block (header line through the next table header / EOF) if present, else append. Idempotent.
36
+ * Preserves all other content; only the framein table is rewritten.
37
+ */
38
+ export function applyCodexMcp(existing, name = 'framein', entry = FRAMEIN_ENTRY) {
39
+ const header = `[mcp_servers.${name}]`;
40
+ const block = [
41
+ header,
42
+ `command = ${JSON.stringify(entry.command)}`,
43
+ `args = [${entry.args.map((a) => JSON.stringify(a)).join(', ')}]`,
44
+ ];
45
+ const text = existing ?? '';
46
+ const lines = text.split('\n');
47
+ const start = lines.findIndex((l) => l.trim() === header);
48
+ if (start === -1) {
49
+ const base = text.trim();
50
+ return (base ? base + '\n\n' : '') + block.join('\n') + '\n';
51
+ }
52
+ // Block ends at the next table header (any `[...]`) or EOF.
53
+ let end = lines.length;
54
+ for (let i = start + 1; i < lines.length; i++) {
55
+ if (/^\s*\[/.test(lines[i])) {
56
+ end = i;
57
+ break;
58
+ }
59
+ }
60
+ const before = lines.slice(0, start);
61
+ const after = lines.slice(end);
62
+ const sep = after.length && after[0].trim() !== '' ? [''] : []; // keep a blank line before a following table
63
+ return [...before, ...block, ...sep, ...after].join('\n').replace(/\n*$/, '\n');
64
+ }
65
+ /**
66
+ * Read the connection state of a server from `claude mcp list` output. `connected`/`failed` when
67
+ * a health marker (✓/✗) is shown, `registered` when listed without one, `absent` when not found.
68
+ * (The verify step; the spawn that produces `output` is the live B-layer piece.)
69
+ */
70
+ export function parseClaudeMcpList(output, name = 'framein') {
71
+ for (const raw of output.split('\n')) {
72
+ const line = raw.trim();
73
+ const colon = line.indexOf(':');
74
+ if (colon === -1)
75
+ continue;
76
+ if (line.slice(0, colon).trim() !== name)
77
+ continue;
78
+ if (/✗|✘|fail/i.test(line))
79
+ return 'failed';
80
+ if (/✓|✔|connected/i.test(line))
81
+ return 'connected';
82
+ return 'registered';
83
+ }
84
+ return 'absent';
85
+ }