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.
@@ -0,0 +1,138 @@
1
+ // Thin framein MCP surface (F-OWN-1): the minimum tools agents need to share the store.
2
+ // `handleTool` maps a tool call to a store op (unit-tested); `dispatch` handles one JSON-RPC
3
+ // message; `serve` is the stdio loop. The wire format is newline-delimited JSON (NDJSON) —
4
+ // that IS the MCP stdio transport (ADR-0007). MCP stdio is NOT Content-Length framed (that's
5
+ // LSP); headers only appear in MCP's separate Streamable HTTP transport. Live MCP-CLIENT
6
+ // wiring (each CLI launching its own `frame mcp serve`) is the orchestration layer (B).
7
+ import { createInterface } from 'node:readline';
8
+ const obj = (properties, required = []) => ({ type: 'object', properties, required });
9
+ const str = { type: 'string' };
10
+ export const TOOLS = [
11
+ { name: 'append_adr', description: 'record an ADR', inputSchema: obj({ title: str, decision: str, context: str, consequences: str }, ['title']) },
12
+ { name: 'list_adr', description: 'list all ADRs', inputSchema: obj({}) },
13
+ { name: 'get_adr', description: 'get one ADR', inputSchema: obj({ id: { type: 'number' } }, ['id']) },
14
+ { name: 'read_memory', description: 'read scoped memory', inputSchema: obj({ scope: str, key: str }, ['scope', 'key']) },
15
+ { name: 'write_memory', description: 'write scoped memory', inputSchema: obj({ scope: str, key: str, value: {} }, ['scope', 'key', 'value']) },
16
+ { name: 'list_memory', description: 'list a memory scope', inputSchema: obj({ scope: str }, ['scope']) },
17
+ { name: 'get_role', description: 'agent assigned to a role', inputSchema: obj({ role: str }, ['role']) },
18
+ { name: 'get_roles', description: 'all role assignments', inputSchema: obj({}) },
19
+ { name: 'acquire_lock', description: 'acquire the write lock', inputSchema: obj({ holder: str, scope: str }, ['holder']) },
20
+ { name: 'release_lock', description: 'release the write lock', inputSchema: obj({ holder: str, scope: str }, ['holder']) },
21
+ ];
22
+ const TOOL_NAMES = new Set(TOOLS.map((t) => t.name));
23
+ /** Protocol versions we accept, newest first. Negotiation echoes the client's if supported. */
24
+ export const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
25
+ // Advertised to clients; keep loosely in sync with package.json (a string is required by spec).
26
+ const SERVER_INFO = { name: 'framein', version: '0.0.1' };
27
+ export function handleTool(store, name, args = {}) {
28
+ switch (name) {
29
+ case 'append_adr': {
30
+ const title = String(args.title ?? '');
31
+ if (!title)
32
+ throw new Error('append_adr requires a title');
33
+ const a = store.appendAdr({
34
+ title,
35
+ decision: String(args.decision ?? title),
36
+ context: args.context,
37
+ consequences: args.consequences,
38
+ });
39
+ return { id: a.id };
40
+ }
41
+ case 'list_adr': return store.listAdrs();
42
+ case 'get_adr': return store.getAdr(Number(args.id)) ?? null;
43
+ case 'read_memory': return store.getMemory(String(args.scope), String(args.key)) ?? null;
44
+ case 'write_memory':
45
+ store.setMemory(String(args.scope), String(args.key), args.value);
46
+ return { ok: true };
47
+ case 'list_memory': return store.listMemory(String(args.scope));
48
+ case 'get_role': return store.getRole(args.role) ?? null;
49
+ case 'get_roles': return store.getRoles();
50
+ case 'acquire_lock': return { acquired: store.acquireLock(String(args.holder), { scope: args.scope }) };
51
+ case 'release_lock': return { released: store.releaseLock(String(args.holder), { scope: args.scope }) };
52
+ default: throw new Error(`unknown tool: ${name}`);
53
+ }
54
+ }
55
+ /**
56
+ * Dispatch one parsed JSON-RPC value. Never throws. Returns a reply object for requests, or
57
+ * `null` for notifications and for anything we can't reply to (no id). When a `session` is
58
+ * supplied, the initialize-first lifecycle is enforced (ADR-0007); without one, dispatch stays
59
+ * lenient (used by unit tests). `initialize` and `ping` are always allowed pre-init.
60
+ */
61
+ export function dispatch(store, req, session) {
62
+ const isObj = typeof req === 'object' && req !== null && !Array.isArray(req);
63
+ const r = (isObj ? req : {});
64
+ const hasId = isObj && 'id' in r && r.id !== null && r.id !== undefined;
65
+ const id = hasId ? r.id : null;
66
+ const params = (typeof r.params === 'object' && r.params !== null ? r.params : {});
67
+ const reply = (result) => (hasId ? { jsonrpc: '2.0', id, result } : null);
68
+ const errReply = (code, message) => (hasId ? { jsonrpc: '2.0', id, error: { code, message } } : null);
69
+ if (typeof r.method !== 'string')
70
+ return errReply(-32600, 'invalid request');
71
+ const method = r.method;
72
+ // Lifecycle methods allowed before initialization completes.
73
+ if (method === 'notifications/initialized') {
74
+ if (session)
75
+ session.initialized = true;
76
+ return null;
77
+ }
78
+ if (method === 'initialize') {
79
+ if (session)
80
+ session.initialized = false; // re-init resets; the initialized notification re-arms it
81
+ const want = params.protocolVersion;
82
+ const protocolVersion = typeof want === 'string' && PROTOCOL_VERSIONS.includes(want) ? want : PROTOCOL_VERSIONS[0];
83
+ return reply({ protocolVersion, serverInfo: SERVER_INFO, capabilities: { tools: { listChanged: false } } });
84
+ }
85
+ if (method === 'ping')
86
+ return reply({});
87
+ if (session && !session.initialized)
88
+ return errReply(-32600, 'received request before initialization');
89
+ try {
90
+ switch (method) {
91
+ case 'tools/list': return reply({ tools: TOOLS });
92
+ case 'tools/call': {
93
+ const name = params.name;
94
+ if (typeof name !== 'string' || !TOOL_NAMES.has(name))
95
+ return errReply(-32602, `unknown tool: ${String(name)}`);
96
+ const args = (typeof params.arguments === 'object' && params.arguments !== null ? params.arguments : {});
97
+ try {
98
+ const out = handleTool(store, name, args);
99
+ return reply({ content: [{ type: 'text', text: JSON.stringify(out) }], isError: false });
100
+ }
101
+ catch (e) {
102
+ // Tool EXECUTION errors stay in-band (isError) so the model sees them and can recover.
103
+ return reply({ content: [{ type: 'text', text: e.message }], isError: true });
104
+ }
105
+ }
106
+ default: return errReply(-32601, `unknown method: ${method}`);
107
+ }
108
+ }
109
+ catch (e) {
110
+ return errReply(-32603, e.message);
111
+ }
112
+ }
113
+ /**
114
+ * MCP stdio loop: newline-delimited JSON-RPC over stdin/stdout (the MCP stdio transport — not
115
+ * Content-Length framing, ADR-0007). A malformed line is answered with a JSON-RPC parse error
116
+ * (-32700), not silently dropped. Only valid MCP messages are written to `output` (stdout
117
+ * hygiene); diagnostics belong on stderr. Consumes input until EOF.
118
+ */
119
+ export async function serve(store, input = process.stdin, output = process.stdout) {
120
+ const rl = createInterface({ input, crlfDelay: Infinity });
121
+ const session = { initialized: false };
122
+ for await (const line of rl) {
123
+ const t = line.trim();
124
+ if (!t)
125
+ continue;
126
+ let req;
127
+ try {
128
+ req = JSON.parse(t);
129
+ }
130
+ catch {
131
+ output.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }) + '\n');
132
+ continue;
133
+ }
134
+ const res = dispatch(store, req, session);
135
+ if (res)
136
+ output.write(JSON.stringify(res) + '\n');
137
+ }
138
+ }
@@ -0,0 +1,63 @@
1
+ // Pure core for the lobby's inline `/` command palette (Claude-style, non-modal). Unlike the modal
2
+ // picker (select.ts), here the TYPED LINE is primary: a suggestion list appears *below* the line you're
3
+ // editing as soon as it starts with `/`, filters as you type, and ⏎ runs exactly what you typed — it
4
+ // never force-selects the top item. ↑/↓ opt into picking a suggestion; ⏎ then runs the highlighted one.
5
+ // No npm deps, no I/O — the raw-mode stdin loop + ANSI redraw live in cli.ts (readLobbyLine).
6
+ export function initPalette(buf = '') { return { buf, index: 0, navigated: false }; }
7
+ /** Suggestions are shown ONLY while the buffer is a slash-command being typed (`/…`). Filtered by the
8
+ * text after the leading `/` (substring over cmd + desc). Empty when the line isn't a slash command —
9
+ * so bare verbs / agent names (`verify`, `codex fix bug`) type freely with no popup. */
10
+ export function paletteSuggestions(buf, cmds) {
11
+ if (!buf.startsWith('/'))
12
+ return [];
13
+ const q = buf.slice(1).trim().toLowerCase();
14
+ if (!q)
15
+ return cmds;
16
+ return cmds.filter((c) => `${c.cmd} ${c.desc}`.toLowerCase().includes(q));
17
+ }
18
+ /** Pure reducer: (state, key) → next state OR a terminal step. Mirrors select.ts but the default ⏎
19
+ * action is "run what I typed", not "accept the highlighted item". */
20
+ export function reducePaletteKey(state, key, cmds) {
21
+ const sugg = paletteSuggestions(state.buf, cmds);
22
+ const n = sugg.length;
23
+ const idx = n ? Math.min(state.index, n - 1) : 0;
24
+ if (key.ctrl && key.name === 'c')
25
+ return { kind: 'sigint' };
26
+ if (key.ctrl && key.name === 'd')
27
+ return state.buf ? { kind: 'edit', state } : { kind: 'exit' };
28
+ if (key.name === 'escape')
29
+ return { kind: 'edit', state: initPalette('') }; // close the palette / clear the line
30
+ if (key.name === 'return' || key.name === 'enter') {
31
+ if (n && state.navigated)
32
+ return { kind: 'submit', line: sugg[idx].cmd }; // user picked one → run it
33
+ const line = state.buf.trim();
34
+ if (line === '' || line === '/')
35
+ return { kind: 'edit', state }; // nothing meaningful typed → no-op
36
+ return { kind: 'submit', line };
37
+ }
38
+ if (key.name === 'up')
39
+ return { kind: 'edit', state: { ...state, index: n ? (idx - 1 + n) % n : 0, navigated: true } };
40
+ if (key.name === 'down')
41
+ return { kind: 'edit', state: { ...state, index: n ? (idx + 1) % n : 0, navigated: true } };
42
+ if (key.name === 'tab')
43
+ return n ? { kind: 'edit', state: { buf: `${sugg[idx].cmd} `, index: 0, navigated: false } } : { kind: 'edit', state };
44
+ if (key.name === 'backspace')
45
+ return { kind: 'edit', state: { ...state, buf: state.buf.slice(0, -1), index: 0, navigated: false } };
46
+ // A single printable character extends the line (control sequences ignored). Typing resets the
47
+ // highlight so ⏎ goes back to "run what I typed" until the user navigates again.
48
+ const ch = key.sequence;
49
+ if (ch && ch.length === 1 && ch >= ' ' && !key.ctrl) {
50
+ return { kind: 'edit', state: { ...state, buf: state.buf + ch, index: 0, navigated: false } };
51
+ }
52
+ return { kind: 'edit', state };
53
+ }
54
+ /** Pure renderer for the suggestion list drawn below the input line (no ANSI; the I/O layer colors the
55
+ * hints). Returns [] when no suggestions are visible. The input line itself is drawn by the caller. */
56
+ export function renderPaletteSuggestions(state, cmds, marker = '>') {
57
+ const sugg = paletteSuggestions(state.buf, cmds);
58
+ if (!sugg.length)
59
+ return [];
60
+ const w = Math.max(...sugg.map((c) => c.cmd.length));
61
+ const idx = Math.min(state.index, sugg.length - 1);
62
+ return sugg.map((c, i) => `${i === idx ? `${marker} ` : ' '}${c.cmd.padEnd(w)} ${c.desc}`);
63
+ }
@@ -0,0 +1,65 @@
1
+ // Projects the single source of truth into the three native context files.
2
+ // The canonical CORE BLOCK is byte-identical across all three => guaranteed sync.
3
+ import { buildAdrDigest } from './adr.js';
4
+ import { renderContractDigest } from './task.js';
5
+ import { wrapManaged, upsertManagedBlock } from './managedBlock.js';
6
+ export function buildCoreBlock(state) {
7
+ const rulesVal = state.config['rules'];
8
+ const rules = typeof rulesVal === 'string' && rulesVal.trim() ? rulesVal.trim() : '_No project rules defined._';
9
+ const roleEntries = Object.entries(state.roles);
10
+ const roleLines = roleEntries.length === 0
11
+ ? '_No roles assigned._'
12
+ : roleEntries.map(([r, a]) => `- **${r}** → ${a}`).join('\n');
13
+ const contract = state.taskContract ? renderContractDigest(state.taskContract) : '_No active task contract._';
14
+ // Operating guide for the AGENT (ADR-0012): framein is driven from inside your normal flow, not by the
15
+ // human typing long commands. Run any verb with the shell (`framein <verb>`) or the host slash command.
16
+ const guide = [
17
+ 'This project is kept aligned by **framein** (intent → validation → continuity). Drive it from your',
18
+ 'normal flow — run a verb with the shell (`framein <verb>`) or the host command (`/fr:<verb>`, Codex',
19
+ '`$fr-<verb>`). Automation-facing verbs expose `--json`; use the generated wrapper syntax when available.',
20
+ 'On Windows PowerShell, use `framein.cmd <verb>` if the npm `.ps1` shim is blocked; generated wrappers already do this.',
21
+ '',
22
+ '- **At session start (and after any model switch)** — run `framein capsule` first to load where the',
23
+ ' project left off (last-green commit, recent changes, open blocker, decisions). This is how context',
24
+ ' follows you across models — don\'t ask the user to re-explain.',
25
+ '- **Task start** — fix what "done" means before coding: `framein start "<goal>"`, then',
26
+ ' `framein task amend acceptance|nongoal|protected "<…>"`. Changes auto-apply and show up in `git diff` —',
27
+ ' keep them honest; the human reviews. Don\'t silently drift the contract.',
28
+ '- **Before you claim done** — run the Validation Gate: `framein verify`. Never report "done" without it.',
29
+ '- **Ship** — `framein ship` for commit/deploy readiness.',
30
+ '- **Risky change** (auth · payments · migrations · deploy · secrets) — `framein risk`, then satisfy the required gates.',
31
+ '- **Stuck in a repeat-fix loop** — `framein rescue` (it proposes options; it never auto-acts).',
32
+ '- **Want a second opinion** — `framein challenge "<proposal>" --run` gets an INDEPENDENT model\'s verdict',
33
+ ' (a *different* model reviews — don\'t write the critique yourself; you keep the lead and `decide`).',
34
+ '- **Hand work to another model** — `framein capsule <agent>`, then exit; framein switches to that model,',
35
+ ' which loads the capsule on arrival. The context follows the handoff automatically.',
36
+ '',
37
+ 'The **Task Contract** below is the definition of done — honor it. The human stays the final gate.',
38
+ ].join('\n');
39
+ return [
40
+ '## Working with framein', guide, '',
41
+ '## Task Contract', contract, '',
42
+ '## Project Rules', rules, '',
43
+ '_Project defaults — change them with `framein rules set "<…>"` (edits typed directly here are overwritten on sync). They guide the agent; the Validation Gate is what\'s enforced._', '',
44
+ '## Agent Roles', roleLines, '',
45
+ '## Architecture Decisions (digest)', buildAdrDigest(state.adrs),
46
+ ].join('\n');
47
+ }
48
+ /** The managed block (markers + canonical core), byte-identical across all three files. */
49
+ export function renderManagedBlock(state) {
50
+ return wrapManaged(buildCoreBlock(state));
51
+ }
52
+ /** A from-scratch native file (heading + managed block); used when no file exists yet. */
53
+ function projectFresh(title, state) {
54
+ return upsertManagedBlock(null, title, renderManagedBlock(state));
55
+ }
56
+ export function projectClaudeMd(state) { return projectFresh('CLAUDE.md', state); }
57
+ export function projectAgentsMd(state) { return projectFresh('AGENTS.md', state); }
58
+ export function projectGeminiMd(state) { return projectFresh('GEMINI.md', state); }
59
+ export function projectAll(state) {
60
+ return {
61
+ 'CLAUDE.md': projectClaudeMd(state),
62
+ 'AGENTS.md': projectAgentsMd(state),
63
+ 'GEMINI.md': projectGeminiMd(state),
64
+ };
65
+ }
package/dist/quota.js ADDED
@@ -0,0 +1,30 @@
1
+ // Reactive quota detection (F-ROLE-3). Parse a CLI's stderr/output for rate-limit / quota /
2
+ // overload signals so the orchestrator can fail over to another agent. This is NOT "bypassing
3
+ // limits" — it routes work within each subscription's normal use. Pure + fixture-tested; the
4
+ // live capture of `output` (a real CLI run) is the B-layer spawn (see delegate.ts / M10).
5
+ // Checked in order of severity: a hard quota wins over a transient rate-limit / overload.
6
+ const QUOTA = /quota|usage limit|resource_exhausted|insufficient_quota/i;
7
+ const RATE = /\b429\b|rate[\s_-]?limit|too many requests/i;
8
+ const OVERLOAD = /overloaded|server is busy|try again later|\b529\b/i;
9
+ function parseRetryAfter(text) {
10
+ const m = text.match(/retry[\s-]?after[:\s]+(\d+)/i) ?? text.match(/try again in (\d+)\s*(seconds?|secs?|minutes?|mins?)/i);
11
+ if (!m)
12
+ return undefined;
13
+ const n = Number(m[1]);
14
+ if (!Number.isFinite(n))
15
+ return undefined;
16
+ const unit = (m[2] ?? 's').toLowerCase();
17
+ return unit.startsWith('min') ? n * 60 : n;
18
+ }
19
+ /** Classify a single agent's output. `exhausted` is the failover trigger. */
20
+ export function detectQuotaSignal(agent, output) {
21
+ const text = output ?? '';
22
+ let kind;
23
+ if (QUOTA.test(text))
24
+ kind = 'quota';
25
+ else if (RATE.test(text))
26
+ kind = 'rate-limit';
27
+ else if (OVERLOAD.test(text))
28
+ kind = 'overloaded';
29
+ return { agent, exhausted: kind !== undefined, kind, retryAfterSec: parseRetryAfter(text) };
30
+ }
package/dist/recipe.js ADDED
@@ -0,0 +1,55 @@
1
+ // Frame Recipe (F-LOOP-8, ADR-0008): a VENDOR-NEUTRAL task protocol (feature/bugfix/ship) that we
2
+ // COMPILE/PROJECT onto each CLI's native features — NOT "run a Claude skill inside Codex" (that's a
3
+ // category error, ADR-0002/0004). The same numbered protocol body is emitted for every agent; only
4
+ // the header naming the agent's native mechanism differs. Shared state stays in framein MCP + ledger.
5
+ import { PLAIN } from './ui/theme.js';
6
+ export const RECIPES = [
7
+ { name: 'feature', trigger: 'feature', steps: [
8
+ { role: 'lead', action: 'define_contract' },
9
+ { role: 'implementer', action: 'implement' },
10
+ { action: 'run_validation', required: ['tests', 'build'] },
11
+ { role: 'reviewer', action: 'blocker_review', readOnly: true },
12
+ { role: 'implementer', action: 'resolve_findings' },
13
+ { action: 'human_approval' },
14
+ ] },
15
+ { name: 'bugfix', trigger: 'bugfix', steps: [
16
+ { role: 'implementer', action: 'reproduce' },
17
+ { role: 'reviewer', action: 'root_cause', readOnly: true },
18
+ { role: 'implementer', action: 'minimal_fix' },
19
+ { action: 'run_validation', required: ['regression_test'] },
20
+ { action: 'human_approval' },
21
+ ] },
22
+ { name: 'ship', trigger: 'ship', steps: [
23
+ { action: 'verify_changes', required: ['tests', 'build'] },
24
+ { action: 'risk_check' },
25
+ { role: 'explainer', action: 'ownership_brief' },
26
+ { action: 'human_approval' },
27
+ ] },
28
+ ];
29
+ // How each CLI expresses a recipe natively (we project onto these; we do not cross-execute).
30
+ const NATIVE_MECHANISM = {
31
+ claude: 'a Claude Skill + hooks + subagent guidance',
32
+ codex: 'a Codex skill / plugin / workflow',
33
+ gemini: 'a Gemini extension / skill / hooks',
34
+ };
35
+ export function listRecipes() { return RECIPES; }
36
+ export function getRecipe(name) { return RECIPES.find((r) => r.name === name); }
37
+ function stepLines(r) {
38
+ return r.steps.map((s, i) => {
39
+ const who = s.role ? `[${s.role}] ` : '';
40
+ const ro = s.readOnly ? ' (read-only)' : '';
41
+ const req = s.required ? ` — required: ${s.required.join(', ')}` : '';
42
+ return `${i + 1}. ${who}${s.action}${ro}${req}`;
43
+ });
44
+ }
45
+ export function renderRecipe(r, ui = PLAIN) {
46
+ return [ui.tone(`recipe: ${r.name} (trigger: ${r.trigger})`, 'muted'), ...stepLines(r)].join('\n');
47
+ }
48
+ /** Project a recipe onto one agent's native mechanism. Body (numbered steps) is identical per agent. */
49
+ export function compileRecipe(r, agent) {
50
+ return [
51
+ `# ${r.name} — compiled for ${agent} as ${NATIVE_MECHANISM[agent]}`,
52
+ ...stepLines(r),
53
+ 'Shared state: framein MCP + task ledger (the contract, decisions, and validation results all agents read).',
54
+ ].join('\n');
55
+ }
package/dist/rescue.js ADDED
@@ -0,0 +1,38 @@
1
+ // Rescue Mode (F-LOOP-3, ADR-0008): promote the anomaly detector from a side feature to a
2
+ // flagship. When the ledger shows a repair loop, assemble a rescue report — the signals, the last
3
+ // green checkpoint, and three options (diagnose / rewind / continue) — and NEVER act automatically.
4
+ // Pure logic; the actual model diagnosis (option A) and the git rewind (option B) live in cli.ts.
5
+ import { PLAIN } from './ui/theme.js';
6
+ const short = (sha) => sha.slice(0, 7);
7
+ export function buildRescue(signals, opts = {}) {
8
+ const triggered = signals.length > 0;
9
+ const options = [];
10
+ if (triggered) {
11
+ const who = opts.reviewer ?? 'the reviewer role';
12
+ options.push({ key: 'A', label: `Ask ${who} to diagnose without editing` });
13
+ if (opts.lastGreen) {
14
+ options.push({ key: 'B', label: `Rewind to checkpoint ${short(opts.lastGreen.sha)}${opts.lastGreen.label ? ` (${opts.lastGreen.label})` : ''}` });
15
+ }
16
+ options.push({ key: opts.lastGreen ? 'C' : 'B', label: 'Continue with the current agent' });
17
+ }
18
+ return { triggered, signals, lastGreen: opts.lastGreen, reviewer: opts.reviewer, options };
19
+ }
20
+ export function renderRescue(report, ui = PLAIN) {
21
+ if (!report.triggered)
22
+ return 'No repair loop detected. (rescue is anomaly-triggered — ADR-0005)';
23
+ const lines = [ui.tone('Framein detected a repair loop.', 'danger'), ''];
24
+ for (const s of report.signals)
25
+ lines.push(` ${ui.tone(ui.sym.warn, 'warning')} ${s.message}`);
26
+ lines.push('');
27
+ if (report.lastGreen) {
28
+ lines.push(`Last green checkpoint: ${short(report.lastGreen.sha)}${report.lastGreen.label ? ` (${report.lastGreen.label})` : ''}`);
29
+ }
30
+ else {
31
+ lines.push('No green checkpoint recorded (run `frame checkpoint` at a known-good state).');
32
+ }
33
+ lines.push('', 'Recommended:');
34
+ for (const o of report.options)
35
+ lines.push(` ${ui.tone(o.key, 'brand')}. ${o.label}`);
36
+ lines.push('', ui.tone('No action taken automatically.', 'muted'));
37
+ return lines.join('\n');
38
+ }
package/dist/roles.js ADDED
@@ -0,0 +1,61 @@
1
+ // Role presets + routing score function (PRD section 5.2).
2
+ // Vendor-role mapping is NOT hardcoded: these are defaults the user can override.
3
+ import { AGENTS, ROLES } from './types.js';
4
+ /** Runtime guard: is `x` one of the three first-class agents? */
5
+ export function isAgent(x) {
6
+ return AGENTS.includes(x);
7
+ }
8
+ /** Runtime guard: is `x` one of the five role presets? */
9
+ export function isRole(x) {
10
+ return ROLES.includes(x);
11
+ }
12
+ export const DEFAULT_ROLE_PRIORITY = {
13
+ lead: ['claude', 'codex'],
14
+ implementer: ['claude', 'codex'],
15
+ reviewer: ['codex', 'claude'],
16
+ explainer: ['gemini', 'claude'],
17
+ researcher: ['gemini', 'claude'],
18
+ };
19
+ /** Compliance rule (PRD non-goal): consumer Gemini login is not permitted. */
20
+ export function isForbiddenAuth(agent, auth) {
21
+ return agent === 'gemini' && auth === 'consumer-login';
22
+ }
23
+ /** Repo-local routing bonus: reward local success, penalize local quota trouble (F-LOOP-7). */
24
+ export function repoBonus(st) {
25
+ if (!st || st.delegations === 0)
26
+ return 0;
27
+ const successRate = (st.delegations - st.failures) / st.delegations; // 0..1
28
+ return successRate * 2 - st.quotaHits * 0.5;
29
+ }
30
+ export const POLICY_PENALTY = Number.POSITIVE_INFINITY;
31
+ // score = roleFit + quotaScore - failurePenalty - costPenalty
32
+ // forbidden auth combos => -Infinity (never selected).
33
+ export function scoreAgent(agent, ctx) {
34
+ const auth = ctx.authMode[agent];
35
+ if (auth && isForbiddenAuth(agent, auth))
36
+ return -POLICY_PENALTY;
37
+ if (ctx.unavailable?.[agent])
38
+ return -POLICY_PENALTY; // quota-exhausted / down => fail over to another agent
39
+ const priority = (ctx.rolePriority ?? DEFAULT_ROLE_PRIORITY)[ctx.role] ?? [];
40
+ const idx = priority.indexOf(agent);
41
+ const roleFit = idx === -1 ? 0 : priority.length - idx;
42
+ const quota = ctx.remainingQuota?.[agent];
43
+ const quotaScore = quota === undefined ? 1 : quota * 2;
44
+ const failurePenalty = (ctx.recentFailures?.[agent] ?? 0) * 1.5;
45
+ const costPenalty = ctx.costBand?.[agent] ?? 0;
46
+ return roleFit + quotaScore + repoBonus(ctx.repoStats?.[agent]) - failurePenalty - costPenalty;
47
+ }
48
+ export function selectAgent(candidates, ctx) {
49
+ let best = null;
50
+ let bestScore = -POLICY_PENALTY;
51
+ for (const a of candidates) {
52
+ const s = scoreAgent(a, ctx);
53
+ if (s === -POLICY_PENALTY)
54
+ continue;
55
+ if (best === null || s > bestScore) {
56
+ best = a;
57
+ bestScore = s;
58
+ }
59
+ }
60
+ return best;
61
+ }
package/dist/select.js ADDED
@@ -0,0 +1,50 @@
1
+ // Pure core for the zero-dep arrow-key picker. The raw-mode stdin loop + ANSI redraw live in cli.ts
2
+ // (promptSelect); this module is the testable reducer: (state, keypress) -> next state OR a terminal
3
+ // action. No npm deps, no I/O. `index` always refers to the CURRENTLY VISIBLE (filtered) list.
4
+ /** Case-insensitive substring filter over label + hint + value. Order preserved. */
5
+ export function filterItems(items, query) {
6
+ const q = query.trim().toLowerCase();
7
+ if (!q)
8
+ return items;
9
+ return items.filter((it) => `${it.label} ${it.hint ?? ''} ${it.value}`.toLowerCase().includes(q));
10
+ }
11
+ export function initSelect(items) {
12
+ return { items, query: '', index: 0 };
13
+ }
14
+ /** Pure reducer. Returns the next state (kind 'move') or a terminal action ('accept' | 'cancel'). */
15
+ export function reduceSelectKey(state, key) {
16
+ const visible = filterItems(state.items, state.query);
17
+ const n = visible.length;
18
+ const idx = n ? Math.min(state.index, n - 1) : 0;
19
+ if (key.ctrl && key.name === 'c')
20
+ return { kind: 'cancel' };
21
+ if (key.name === 'escape')
22
+ return { kind: 'cancel' };
23
+ if (key.name === 'return' || key.name === 'enter') {
24
+ return n ? { kind: 'accept', value: visible[idx].value } : { kind: 'move', state };
25
+ }
26
+ if (key.name === 'up')
27
+ return { kind: 'move', state: { ...state, index: n ? (idx - 1 + n) % n : 0 } };
28
+ if (key.name === 'down')
29
+ return { kind: 'move', state: { ...state, index: n ? (idx + 1) % n : 0 } };
30
+ if (key.name === 'backspace')
31
+ return { kind: 'move', state: { ...state, query: state.query.slice(0, -1), index: 0 } };
32
+ // A single printable character extends the type-to-filter query (control sequences are ignored).
33
+ const ch = key.sequence;
34
+ if (ch && ch.length === 1 && ch >= ' ' && !key.ctrl) {
35
+ return { kind: 'move', state: { ...state, query: state.query + ch, index: 0 } };
36
+ }
37
+ return { kind: 'move', state };
38
+ }
39
+ /** Pure renderer: the lines to draw (no ANSI; the I/O layer colors them). First line is the label. */
40
+ export function renderSelectLines(label, state, marker = '>') {
41
+ const visible = filterItems(state.items, state.query);
42
+ const head = state.query ? `${label} (filter: ${state.query})` : label;
43
+ if (!visible.length)
44
+ return [head, ' (no matches — backspace to clear)'];
45
+ const idx = Math.min(state.index, visible.length - 1);
46
+ return [head, ...visible.map((it, i) => {
47
+ const hint = it.hint ? ` ${it.hint}` : '';
48
+ return `${i === idx ? `${marker} ` : ' '}${it.label}${hint}`;
49
+ })];
50
+ }
package/dist/shell.js ADDED
@@ -0,0 +1,127 @@
1
+ // Optional interactive `framein` lobby (ADR-0010, layer 4). A zero-dep readline *switchboard*: run framein
2
+ // verbs inline, switch the lead agent, and hand the terminal to a lead's NATIVE TUI via stdio:'inherit'
3
+ // (framein pauses while the lead drives, resumes on exit). Simultaneous overlay of framein + a live TUI
4
+ // would require node-pty (a native dep) — deferred/optional, never bundled (ADR-0010). This module is the
5
+ // PURE line router (fully unit-tested); the I/O loop (readline + spawn) is the thin wrapper in cli.ts.
6
+ import { AGENTS } from './types.js';
7
+ import { isAgent } from './roles.js';
8
+ /** Split a lobby line into tokens, honoring "double" and 'single' quotes (quotes stripped) so multi-word
9
+ * values like `start "add Google login"` survive as one token instead of leaking literal quotes. */
10
+ export function tokenizeLine(line) {
11
+ const tokens = [];
12
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
13
+ let m;
14
+ while ((m = re.exec(line)) !== null)
15
+ tokens.push(m[1] ?? m[2] ?? m[3]);
16
+ return tokens;
17
+ }
18
+ /** Map one input line to an action. Pure: no I/O, no agent launch — the loop performs the effect. */
19
+ export function routeShellLine(line, state) {
20
+ const trimmed = line.trim();
21
+ if (!trimmed)
22
+ return { kind: 'noop' };
23
+ const tokens = tokenizeLine(trimmed);
24
+ const head = tokens[0].toLowerCase();
25
+ const rest = tokens.slice(1);
26
+ if (head === 'exit' || head === 'quit' || head === '/exit' || head === '/quit')
27
+ return { kind: 'exit' };
28
+ if (head === 'help' || head === '/help' || head === '?')
29
+ return { kind: 'help' };
30
+ if (head === '/lead') {
31
+ if (rest.length === 0)
32
+ return { kind: 'pickLead' }; // bare /lead → interactive picker (TTY); printed fallback otherwise
33
+ const agent = rest[0].toLowerCase();
34
+ if (!isAgent(agent))
35
+ return { kind: 'error', message: `Unknown agent '${rest[0]}'. Valid: ${AGENTS.join(', ')}` };
36
+ return { kind: 'setLead', agent };
37
+ }
38
+ if (head === '/go') {
39
+ const prompt = rest.join(' ').trim();
40
+ return { kind: 'launchLead', agent: state.lead, prompt: prompt || undefined };
41
+ }
42
+ if (head === '/trust')
43
+ return { kind: 'toggleTrust' }; // arm/disarm permission-bypass for the next /go (time-boxed)
44
+ // A bare agent name (optionally with a prompt) switches to + launches that agent's native TUI.
45
+ if (isAgent(head)) {
46
+ const prompt = rest.join(' ').trim();
47
+ return { kind: 'launchLead', agent: head, prompt: prompt || undefined };
48
+ }
49
+ // Otherwise it's a framein command — `/verify` and `verify` both reach the engine.
50
+ const verb = head.startsWith('/') ? head.slice(1) : head;
51
+ if (verb === 'lobby' || verb === 'shell')
52
+ return { kind: 'error', message: 'Already in the lobby.' };
53
+ return { kind: 'engine', args: [verb, ...rest] };
54
+ }
55
+ /** Rows for the context card shown right before handing the terminal to a lead's native TUI (`/go`).
56
+ * Pure: the loop reads these from the store and renders them, so the user carries intent INTO the
57
+ * native UI. We surface state here; we never screen-scrape the TUI itself (ADR-0009). */
58
+ export function handoffCardRows(info) {
59
+ const rows = [['lead', info.lead]];
60
+ if (info.reviewer)
61
+ rows.push(['reviewer', info.reviewer]);
62
+ rows.push(['task', info.goal && info.goal.trim() ? info.goal : 'no active contract']);
63
+ if (info.lastGreen)
64
+ rows.push(['last green', info.lastGreen]);
65
+ if (info.blocker)
66
+ rows.push(['blocker', info.blocker]);
67
+ return rows;
68
+ }
69
+ export function renderShellHelp() {
70
+ return [
71
+ 'framein lobby — your switchboard for AI coding. The leading / is optional. Every verb is',
72
+ 'deterministic and LOCAL: you don’t need an agent to run one (the agent just decides when to).',
73
+ '',
74
+ ' Lobby-only — choose who drives & hand off (these live in the lobby, not inside agents):',
75
+ ' /lead switch the lead agent (↑↓ · type to filter · enter)',
76
+ ' /go [task] hand the terminal to the lead — work there, exit (Ctrl-D) to return',
77
+ ' /trust arm/disarm permission-bypass for /go (off by default · 30m)',
78
+ ' /exit leave the lobby (or Ctrl-D on an empty line)',
79
+ ` ${AGENTS.join(' · ')} shortcut: jump straight into that agent (e.g. \`codex fix the bug\`)`,
80
+ '',
81
+ ' framein verbs — run here, inside your agent (/fr:verify · $fr-verify), or as `framein <verb>`:',
82
+ ' start <goal> define what “done” means — the Task Contract',
83
+ ' verify run build/test, check validation against the contract',
84
+ ' ship deployment-readiness gate (blocks if not ready)',
85
+ ' risk blast-radius of the current change',
86
+ ' rescue stuck in a fix-loop? show the loop + safe options',
87
+ ' challenge · decide have another model argue a proposal, then you rule',
88
+ ' task · capsule show/amend the contract · carry state across a model switch',
89
+ ' status project · contract · state',
90
+ '',
91
+ ' Also in the lobby / terminal (not wrapped into agents): init · stats · explain',
92
+ '',
93
+ 'Tip: type / to browse (filters as you type · ↑↓ to pick · ⏎ runs) · full list: framein --help · manual: docs/MANUAL.md',
94
+ ].join('\n');
95
+ }
96
+ /** Verbs offered by lobby Tab-completion (a friendly subset of the full CLI surface). */
97
+ export const LOBBY_VERBS = ['init', 'start', 'verify', 'ship', 'rescue', 'status', 'stats', 'explain', 'risk', 'task', 'checkpoint', 'capsule'];
98
+ const LOBBY_COMMANDS = [...LOBBY_VERBS, ...LOBBY_VERBS.map((v) => `/${v}`), '/lead', '/go', '/trust', '/help', 'exit', 'quit'];
99
+ /** readline completer for the lobby line editor (pure). Completes agent names after `/lead `, otherwise
100
+ * the first token against the verb / slash-command list. Returns [matches, fragmentBeingCompleted]. */
101
+ export function lobbyCompleter(line) {
102
+ const lead = line.match(/^\/lead\s+(\S*)$/i);
103
+ if (lead) {
104
+ const frag = lead[1];
105
+ const hits = AGENTS.filter((a) => a.startsWith(frag.toLowerCase()));
106
+ return [hits.length ? hits : AGENTS.slice(), frag];
107
+ }
108
+ const hits = LOBBY_COMMANDS.filter((c) => c.startsWith(line));
109
+ return [hits, line];
110
+ }
111
+ /** Commands shown in the lobby's live `/` palette (the inline menu opened by typing `/`). All slash-
112
+ * prefixed for consistency; the leading `/` is what surfaces the menu. Curated to what's actually
113
+ * useful FROM the lobby — the deterministic checks ("local, no agent" → they run without an LLM) plus
114
+ * the switchboard verbs. The real coding happens after /go, inside the lead's own UI. (palette.ts) */
115
+ export const LOBBY_PALETTE = [
116
+ { cmd: '/go', desc: 'hand the terminal to the lead agent — where the coding happens' },
117
+ { cmd: '/lead', desc: 'switch the lead agent (claude · codex · gemini)' },
118
+ { cmd: '/trust', desc: 'arm/disarm permission-bypass for /go (time-boxed)' },
119
+ { cmd: '/status', desc: 'project · contract · state (local, no agent)' },
120
+ { cmd: '/verify', desc: 'run build/test, check validation vs the contract (local, no agent)' },
121
+ { cmd: '/ship', desc: 'deployment-readiness check (local, no agent)' },
122
+ { cmd: '/risk', desc: 'blast-radius of the current changes (local, no agent)' },
123
+ { cmd: '/rescue', desc: 'stuck? detect the loop + get options (local, no agent)' },
124
+ { cmd: '/init', desc: 'set up framein in this folder' },
125
+ { cmd: '/help', desc: 'all commands' },
126
+ { cmd: '/exit', desc: 'leave the lobby' },
127
+ ];