framein 0.0.4 → 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/cli.js CHANGED
@@ -1,2090 +1,2150 @@
1
- #!/usr/bin/env node
2
- // framein — the framein orchestrator CLI (prototype subset).
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
- import { spawnSync } from 'node:child_process';
5
- import { createInterface, emitKeypressEvents, moveCursor, clearScreenDown } from 'node:readline';
6
- import { basename, dirname, join } from 'node:path';
7
- import { Store } from './store.js';
8
- import { writeNativeFiles, planNativeFiles } from './fileWriter.js';
9
- import { ROLES, AGENTS } from './types.js';
10
- import { isAgent, isRole, selectAgent, DEFAULT_ROLE_PRIORITY } from './roles.js';
11
- import { buildInvocation, resolveAgent, renderInvocation, invocationCommand, interactiveCommand } from './delegate.js';
12
- import { detectQuotaSignal } from './quota.js';
13
- import { trustPlan, parseDuration, DEFAULT_TRUST_TTL_SEC } from './trust.js';
14
- import { emptyContract, amendContract, contractIssues, renderContractFull, buildGuidedContract, GUIDED_CONTRACT_STEPS } from './task.js';
15
- import { gate, renderGate, renderShip, parseTestSummary } from './evidence.js';
16
- import { buildRescue, renderRescue } from './rescue.js';
17
- import { buildCapsule, renderCapsule } from './capsule.js';
18
- import { newDebate, renderDebate } from './disagree.js';
19
- import { extractJson } from './ingest.js';
20
- import { assessBlastRadius, renderBlast, riskTransition } from './blast.js';
21
- import { computeRepoStats, explainRoute, renderRouteExplain, renderStats } from './stats.js';
22
- import { listRecipes, getRecipe, renderRecipe, compileRecipe } from './recipe.js';
23
- import { parseDiffDebt, renderDebt } from './debt.js';
24
- import { ownershipBrief } from './brief.js';
25
- import { detectMcpFromDisk, detectSkillsFromDisk, findConflicts, frameinMcpRegistration, FRAMEIN_SKILLS } from './detect.js';
26
- import { applyJsonMcp, applyCodexMcp, resolveFrameinEntry } from './mcpRegister.js';
27
- import { detectThrash } from './anomaly.js';
28
- import { serve } from './mcpServer.js';
29
- import { wrapperFiles, WRAP_VERBS, PROVENANCE } from './wrappers.js';
30
- import { routeShellLine, renderShellHelp, handoffCardRows, lobbyCompleter, LOBBY_PALETTE } from './shell.js';
31
- import { initSelect, reduceSelectKey, renderSelectLines } from './select.js';
32
- import { initPalette, reducePaletteKey, renderPaletteSuggestions, paletteSuggestions } from './palette.js';
33
- import { resolveCapabilities } from './ui/capabilities.js';
34
- import { painter } from './ui/theme.js';
35
- import { renderFrame, renderKeyVals } from './ui/banner.js';
36
- const SNAPSHOT_PATH = 'framein.store.json';
37
- const FRAME_DIR = '.frame';
38
- const DB_PATH = join(FRAME_DIR, 'store.db');
39
- /** A user-facing error: printed to stderr, exits 1, never a stack trace. */
40
- class CliError extends Error {
41
- }
42
- function fail(message) { throw new CliError(message); }
43
- function rel(p) { return p.replace(/^[.][/\\]/, ''); }
44
- function sleepMs(ms) {
45
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
46
- }
47
- function openStore() {
48
- if (!existsSync(DB_PATH))
49
- fail('No .frame/store.db found. Run `framein init` first.');
50
- return Store.open(DB_PATH);
51
- }
52
- /** Open the store, run fn, and always close it (even on error). */
53
- function withStore(fn) {
54
- const store = openStore();
55
- try {
56
- return fn(store);
57
- }
58
- finally {
59
- store.close();
60
- }
61
- }
62
- function withCliWriteLock(store, scope, fn) {
63
- const holder = `framein-cli:${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
64
- const deadline = Date.now() + 5000;
65
- while (!store.acquireLock(holder, { scope, ttlMs: 30_000 })) {
66
- if (Date.now() >= deadline) {
67
- throw new CliError(`Write lock '${scope}' is held by '${store.getLockHolder(scope) ?? 'unknown'}'. Retry or run \`framein unlock ${scope}\` if stale.`);
68
- }
69
- sleepMs(50);
70
- }
71
- try {
72
- return fn();
73
- }
74
- finally {
75
- store.releaseLock(holder, { scope });
76
- }
77
- }
78
- function ensureFrameDirIgnored(dir = '.') {
79
- const path = join(dir, '.gitignore');
80
- const entry = '.frame/';
81
- const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
82
- const ignored = existing
83
- .split(/\r?\n/)
84
- .map((line) => line.trim())
85
- .some((line) => line === '.frame/' || line === '.frame');
86
- if (ignored)
87
- return false;
88
- const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
89
- writeFileSync(path, `${existing}${prefix}${entry}\n`, 'utf8');
90
- return true;
91
- }
92
- function parseId(raw, usage) {
93
- const n = Number(raw);
94
- if (!raw || !Number.isInteger(n) || n <= 0)
95
- fail(usage);
96
- return n;
97
- }
98
- // Seeded project rules. Deliberately OUTCOME-oriented and judgment-based, not hard mandates: framein
99
- // ENFORCES the Validation Gate (tests vs the contract), and only SUGGESTS method. So TDD/deep-modules are
100
- // encouraged-with-judgment, not imposed different teams differ, and the gate doesn't need a method
101
- // (ADR-0012 / the "editable opinionated defaults" decision). Editable per project via `framein rules set`.
102
- const DEFAULT_RULES = [
103
- '- Don\'t claim "done" without validation — build/test checks backing the contract must pass. (Test-first/TDD encouraged for non-trivial logic; use judgment on prototypes & throwaways.)',
104
- '- Prefer deep modules keep interfaces small relative to the implementation behind them.',
105
- '- Record significant decisions as ADRs.',
106
- ].join('\n');
107
- function cmdInit(opts = {}) {
108
- mkdirSync(FRAME_DIR, { recursive: true });
109
- const ignoredFrameDir = ensureFrameDirIgnored('.');
110
- const store = Store.open(DB_PATH);
111
- try {
112
- if (store.getAllConfig()['rules'] === undefined) {
113
- store.setConfig('rules', DEFAULT_RULES);
114
- }
115
- if (Object.keys(store.getRoles()).length === 0) {
116
- store.setRole('implementer', 'claude');
117
- store.setRole('reviewer', 'codex');
118
- store.setRole('explainer', 'gemini');
119
- }
120
- if (opts.lead)
121
- store.setRole('implementer', opts.lead); // first-run wizard's chosen lead, in one step
122
- writeNativeFiles('.', store.getState());
123
- // Auto-install host-native wrappers for any agent CLI already on PATH, so /fr:* (Claude/Gemini)
124
- // and $fr-* (Codex) work immediately. Newly-installed agents get offered when you switch to them.
125
- const hosts = WRAP_HOSTS.filter(cliInstalled);
126
- let n = 0;
127
- for (const h of hosts)
128
- n += writeWrappers(h);
129
- if (!opts.quiet) {
130
- // Top-level `framein init`. The lobby first-run path passes quiet (it prints its own folded line).
131
- console.log(`Initialized framein in ${FRAME_DIR}/`);
132
- console.log('Projected synchronized context: CLAUDE.md, AGENTS.md, GEMINI.md');
133
- if (ignoredFrameDir)
134
- console.log('Ignored local cache: .frame/ (use `framein export` for the git-canonical snapshot).');
135
- if (hosts.length)
136
- console.log(`Installed ${n} host wrapper(s) for ${hosts.join(', ')} — /fr:verify (Claude/Gemini) · $fr-verify (Codex).`);
137
- }
138
- return n;
139
- }
140
- finally {
141
- store.close();
142
- }
143
- }
144
- // On Windows the agent runs wrapper commands through PowerShell, where the bare `framein` resolves to
145
- // `framein.ps1` and the default execution policy BLOCKS it (UnauthorizedAccess) even if YOU launched
146
- // the agent from Git Bash, because the agent picks its own shell. `framein.cmd` is policy-proof across
147
- // PowerShell/cmd/Git Bash, so we target it in generated wrappers on Windows. (Per-machine artifacts —
148
- // regenerate with `integrations install` on each OS; the bin name isn't portable across platforms.)
149
- const WRAPPER_BIN = process.platform === 'win32' ? 'framein.cmd' : 'framein';
150
- /** Write a host's wrapper files (idempotent). Returns the count. Shared by `init` auto-install,
151
- * `integrations install`, and the lobby's offer-on-lead-switch. */
152
- function writeWrappers(host) {
153
- let n = 0;
154
- for (const f of wrapperFiles(host, WRAPPER_BIN)) {
155
- mkdirSync(dirname(f.path), { recursive: true });
156
- writeFileSync(f.path, f.content);
157
- n++;
158
- }
159
- return n;
160
- }
161
- /** View / set / reset the project rules (the agent-guidance block). Rules are SUGGESTIONS the agent
162
- * reads the Validation Gate is what's enforced so teams can shape them freely. `set` re-projects so
163
- * the change reaches all three native files (editing the projected block directly is overwritten). */
164
- function cmdRules(args) {
165
- const sub = args[0] ?? 'show';
166
- withStore((store) => {
167
- if (sub === 'set' || sub === 'reset') {
168
- let text;
169
- if (sub === 'reset') {
170
- text = DEFAULT_RULES;
171
- }
172
- else {
173
- const inline = args.slice(1).filter((a) => a !== '--json').join(' ').trim();
174
- const piped = !inline && !process.stdin.isTTY ? (() => { try {
175
- return readFileSync(0, 'utf8').trim();
176
- }
177
- catch {
178
- return '';
179
- } })() : '';
180
- text = (inline || piped).replace(/\\n/g, '\n'); // allow \n escapes in a one-line arg
181
- if (!text)
182
- fail('Usage: framein rules set "<text>" (use \\n for line breaks, or pipe the text on stdin)');
183
- }
184
- store.setConfig('rules', text);
185
- writeNativeFiles('.', store.getState());
186
- console.log(`Project rules ${sub === 'reset' ? 'reset to defaults' : 'updated'}; re-projected to CLAUDE.md, AGENTS.md, GEMINI.md.`);
187
- return;
188
- }
189
- if (sub !== 'show')
190
- fail("Unknown 'rules' subcommand. Use: show | set <text> | reset");
191
- const raw = store.getConfig('rules');
192
- const cur = (typeof raw === 'string' ? raw : '').trim();
193
- if (wantsJson(args)) {
194
- emitJson('rules', { rules: cur });
195
- return;
196
- }
197
- console.log(cur || '_No project rules defined._ (set with `framein rules set "<…>"`)');
198
- });
199
- }
200
- // Terminal capabilities / painter for the current process (style guide §12/§13.2). Recomputed per
201
- // call (cheap); color is auto-off for pipes/CI/--json, so automation output stays plain.
202
- function cliCaps() {
203
- const out = process.stdout;
204
- return resolveCapabilities({
205
- isTTY: Boolean(process.stdout.isTTY),
206
- columns: out.columns,
207
- colorDepth: process.stdout.isTTY && typeof out.getColorDepth === 'function' ? out.getColorDepth() : 1,
208
- platform: process.platform,
209
- env: process.env,
210
- flags: process.argv,
211
- });
212
- }
213
- function cliUi() { return painter(cliCaps()); }
214
- // `--json`: stable machine output for wrappers/automation (ADR-0010). schemaVersion + command.
215
- function wantsJson(args) { return args.includes('--json'); }
216
- function emitJson(command, payload) {
217
- console.log(JSON.stringify({ schemaVersion: 1, command, ...payload }));
218
- }
219
- function cmdStatus(args = []) {
220
- withStore((store) => {
221
- const roles = store.getRoles();
222
- if (wantsJson(args)) {
223
- emitJson('status', {
224
- store: DB_PATH,
225
- lock: store.getLockHolder() ?? null,
226
- roles,
227
- decisions: store.listAdrs().length,
228
- goal: store.getTaskContract()?.goal ?? null,
229
- });
230
- return;
231
- }
232
- // Single-string console.log (not multi-arg) so Node never inspect-colors values out of band —
233
- // color is the painter's job, and this keeps NO_COLOR honored even when FORCE_COLOR is set.
234
- const ui = cliUi();
235
- console.log(ui.bold('framein status'));
236
- console.log(` store : ${DB_PATH}`);
237
- console.log(` lock : ${store.getLockHolder() ?? '(free)'}`);
238
- console.log(` roles : ${Object.keys(roles).length ? Object.entries(roles).map(([r, a]) => `${r}→${a}`).join(', ') : '(none)'}`);
239
- console.log(` decisions : ${store.listAdrs().length}`);
240
- });
241
- }
242
- function cmdRole(args) {
243
- withStore((store) => {
244
- const sub = args[0];
245
- if (sub === 'set') {
246
- const role = args[1];
247
- const agent = args[2];
248
- if (!role || !agent)
249
- fail('Usage: framein role set <role> <agent>');
250
- if (!isRole(role))
251
- fail(`Unknown role '${role}'. Valid: ${ROLES.join(', ')}`);
252
- if (!isAgent(agent))
253
- fail(`Unknown agent '${agent}'. Valid: ${AGENTS.join(', ')}`);
254
- store.setRole(role, agent);
255
- writeNativeFiles('.', store.getState());
256
- console.log(`Set ${role} -> ${agent} (native files re-synced)`);
257
- }
258
- else if (sub === undefined || sub === 'list') {
259
- const roles = store.getRoles();
260
- if (Object.keys(roles).length === 0)
261
- console.log(' (no roles assigned)');
262
- for (const [r, a] of Object.entries(roles))
263
- console.log(` ${r} -> ${a}`);
264
- }
265
- else {
266
- fail(`Unknown 'role' subcommand '${sub}'. Use: set | list`);
267
- }
268
- });
269
- }
270
- function cmdAdr(args) {
271
- withStore((store) => {
272
- const sub = args[0];
273
- if (sub === 'add') {
274
- const title = args.slice(1).join(' ').trim();
275
- if (!title)
276
- fail('Usage: framein adr add <title>');
277
- const adr = store.appendAdr({ title, decision: title });
278
- writeNativeFiles('.', store.getState());
279
- console.log(`Recorded ADR-${adr.id}: ${adr.title} (all three files updated)`);
280
- }
281
- else if (sub === 'supersede') {
282
- const oldId = parseId(args[1], 'Usage: framein adr supersede <id> <title>');
283
- const title = args.slice(2).join(' ').trim();
284
- if (!title)
285
- fail('Usage: framein adr supersede <id> <title>');
286
- if (!store.getAdr(oldId))
287
- fail(`ADR-${oldId} not found`);
288
- if (store.isSuperseded(oldId))
289
- fail(`ADR-${oldId} is already superseded`);
290
- const adr = store.supersedeAdr(oldId, { title, decision: title });
291
- writeNativeFiles('.', store.getState());
292
- console.log(`Recorded ADR-${adr.id} superseding ADR-${oldId} (all three files updated)`);
293
- }
294
- else if (sub === 'show') {
295
- const id = parseId(args[1], 'Usage: framein adr show <id>');
296
- const adr = store.getAdr(id);
297
- if (!adr)
298
- fail(`ADR-${id} not found`);
299
- const status = store.isSuperseded(adr.id) ? 'superseded' : adr.status;
300
- console.log(`ADR-${adr.id}: ${adr.title}`);
301
- console.log(` status : ${status}`);
302
- console.log(` created : ${adr.createdAt}`);
303
- if (adr.supersedes != null)
304
- console.log(` supersedes : ADR-${adr.supersedes}`);
305
- if (adr.authorAgent)
306
- console.log(` author : ${adr.authorAgent}`);
307
- if (adr.context)
308
- console.log(` context : ${adr.context}`);
309
- console.log(` decision : ${adr.decision}`);
310
- if (adr.consequences)
311
- console.log(` consequences: ${adr.consequences}`);
312
- }
313
- else if (sub === undefined || sub === 'list') {
314
- const adrs = store.listAdrs();
315
- if (adrs.length === 0)
316
- console.log(' (no decisions recorded)');
317
- for (const a of adrs) {
318
- const status = store.isSuperseded(a.id) ? 'superseded' : a.status;
319
- console.log(` ADR-${a.id} ${a.title} (${status})`);
320
- }
321
- }
322
- else {
323
- fail(`Unknown 'adr' subcommand '${sub}'. Use: add | supersede | show | list`);
324
- }
325
- });
326
- }
327
- function cmdSync(args) {
328
- withStore((store) => {
329
- if (args.includes('--dry-run')) {
330
- for (const p of planNativeFiles('.', store.getState())) {
331
- console.log(` ${p.changed ? 'CHANGE ' : 'unchanged'} ${rel(p.path)}${p.existed ? '' : ' (new)'}`);
332
- }
333
- console.log('(dry-run: no files written)');
334
- }
335
- else {
336
- const written = writeNativeFiles('.', store.getState());
337
- if (written.length === 0)
338
- console.log('Already in sync (no changes).');
339
- else
340
- console.log(`Synced from source of truth: ${written.map(rel).join(', ')}`);
341
- }
342
- });
343
- }
344
- function cmdUnlock(args) {
345
- withStore((store) => {
346
- const scope = args[0] ?? 'global';
347
- const prev = store.getLockHolder(scope);
348
- store.forceUnlock(scope);
349
- console.log(prev ? `Released write lock on '${scope}' (was held by '${prev}').` : `No active lock on '${scope}'.`);
350
- });
351
- }
352
- function cmdExport(args) {
353
- const out = args.find((a) => !a.startsWith('-')) ?? SNAPSHOT_PATH;
354
- withStore((store) => {
355
- writeFileSync(out, JSON.stringify(store.exportSnapshot(), null, 2) + '\n', 'utf8');
356
- console.log(`Exported canonical snapshot to ${out}`);
357
- });
358
- }
359
- function cmdImport(args) {
360
- const src = args.find((a) => !a.startsWith('-')) ?? SNAPSHOT_PATH;
361
- if (!existsSync(src))
362
- fail(`Snapshot not found: ${src}`);
363
- let snap;
364
- try {
365
- snap = JSON.parse(readFileSync(src, 'utf8'));
366
- }
367
- catch {
368
- return fail(`Not valid JSON: ${src}`);
369
- }
370
- mkdirSync(FRAME_DIR, { recursive: true });
371
- const store = Store.open(DB_PATH);
372
- try {
373
- store.importSnapshot(snap);
374
- writeNativeFiles('.', store.getState());
375
- console.log(`Imported ${src} -> ${DB_PATH} (native files re-synced)`);
376
- }
377
- catch (e) {
378
- fail(`Import failed: ${e.message}`);
379
- }
380
- finally {
381
- store.close();
382
- }
383
- }
384
- const VALUE_FLAGS = new Set(['--ttl']); // flags that consume the following token as their value
385
- function cmdAsk(args) {
386
- const flags = [];
387
- const positional = [];
388
- for (let i = 0; i < args.length; i++) {
389
- const a = args[i];
390
- if (VALUE_FLAGS.has(a)) {
391
- flags.push(a);
392
- i++;
393
- continue;
394
- } // skip the value so it can't leak into the prompt
395
- if (a.startsWith('--')) {
396
- flags.push(a);
397
- continue;
398
- }
399
- positional.push(a);
400
- }
401
- const role = positional[0];
402
- if (!role || !isRole(role))
403
- fail(`Usage: framein ask <role> [prompt] [--show|--run] (role: ${ROLES.join('|')})`);
404
- let prompt = positional.slice(1).join(' ').trim();
405
- const interactive = flags.includes('--interactive');
406
- if (!prompt && !interactive && !process.stdin.isTTY) {
407
- try {
408
- prompt = readFileSync(0, 'utf8').trim();
409
- }
410
- catch { /* no stdin */ }
411
- }
412
- if (!prompt && !interactive)
413
- fail('No prompt. Pipe it on stdin or pass it after the role.');
414
- const useTrust = flags.includes('--trust');
415
- withStore((store) => {
416
- const agent = resolveAgent(store.getRoles(), role);
417
- if (interactive) { // zero-dep human-in-the-loop attach: hand the agent's own TUI the terminal
418
- const cmd = interactiveCommand(agent);
419
- if (flags.includes('--show')) {
420
- console.log(`would attach to ${agent} interactively: ${cmd} (stdio:inherit)`);
421
- return;
422
- }
423
- console.log(`Attaching to ${agent} interactively — framein context is synced; you drive it. Exit the agent to return.`);
424
- store.appendLedger('attach', `${role}:${agent}`);
425
- const res = spawnSync(cmd, { stdio: 'inherit', shell: true });
426
- if (res.error)
427
- fail(`Failed to launch ${agent}: ${res.error.message}`);
428
- return;
429
- }
430
- let trustFlags;
431
- if (useTrust) { // F-TRUST: opt-in, time-boxed permission bypass wired into the spawn
432
- const ttlRaw = (() => { const i = args.indexOf('--ttl'); return i !== -1 ? args[i + 1] : undefined; })();
433
- const plan = trustPlan(agent, { ttlSec: ttlRaw ? parseDuration(ttlRaw) ?? undefined : undefined });
434
- trustFlags = plan.flags;
435
- console.log(`⚠ TRUST ON for ${agent} (time-box ~${Math.round(plan.ttlSec / 60)}m): adds ${plan.flags.join(' ')}`);
436
- for (const w of plan.warnings)
437
- console.log(` ⚠ ${w}`);
438
- }
439
- const inv = buildInvocation(agent, prompt, { trustFlags });
440
- if (flags.includes('--show')) { // safe preview: resolve + build, no spawn, no ledger write
441
- console.log(`would run (${role} ${agent}): ${renderInvocation(inv)}`);
442
- return;
443
- }
444
- store.appendLedger('ask', role, prompt.slice(0, 200));
445
- store.appendLedger('turn', role);
446
- if (useTrust)
447
- store.appendLedger('trust', agent, trustFlags?.join(' ') ?? '');
448
- if (flags.includes('--run')) {
449
- runDelegated(store, role, agent, inv);
450
- return;
451
- }
452
- console.log(`Queued ask for role '${role}' → ${agent} (recorded in the ledger).`);
453
- console.log(` Preview: framein ask ${role} <prompt> --show · live run: --run (spawns the ${agent} CLI headless).`);
454
- const signals = detectThrash(store.listLedger());
455
- if (signals.length) {
456
- console.log(' ⚠ audit signals:');
457
- for (const s of signals)
458
- console.log(` - ${s.message}`);
459
- }
460
- });
461
- }
462
- /**
463
- * Live headless delegation (B-2): spawn the agent's non-interactive CLI, stream its output, record
464
- * the outcome + quota signal (failover hint) + a result snippet (ingest). Verified live against
465
- * real claude (`claude -p`) and codex (`codex exec`); the automated suite covers resolve/build
466
- * (`--show`) and the store recording, not the spawn itself (it needs the real CLI + tokens).
467
- */
468
- function runDelegated(store, role, agent, inv) {
469
- console.log(`Delegating ${role} → ${agent}: ${renderInvocation(inv)}`);
470
- // shell:true resolves npm .cmd shims (codex/gemini) on Windows; the command is FIXED flags only
471
- // (no user input injection-safe), and the prompt is fed via stdin (inv.stdin).
472
- const res = spawnSync(invocationCommand(inv), { input: inv.stdin, encoding: 'utf8', shell: true });
473
- if (res.error) {
474
- store.appendLedger('delegate-fail', `${role}:${agent}`, res.error.message);
475
- fail(`Failed to launch ${agent}: ${res.error.message}`);
476
- }
477
- const stdout = res.stdout ?? '';
478
- const stderr = res.stderr ?? '';
479
- if (res.status !== 0 && !stdout.trim() && /not recognized|not found|no such file/i.test(stderr)) {
480
- store.appendLedger('delegate-fail', `${role}:${agent}`, 'cli-not-found');
481
- fail(`'${inv.command}' not found — install the ${agent} CLI, or use --show to preview the command.`);
482
- }
483
- if (stdout)
484
- process.stdout.write(stdout);
485
- if (stderr)
486
- process.stderr.write(stderr);
487
- const ok = res.status === 0;
488
- const sig = detectQuotaSignal(agent, `${stdout}\n${stderr}`);
489
- if (sig.exhausted) {
490
- store.appendLedger('quota', agent, sig.kind ?? '');
491
- const alt = selectAgent(DEFAULT_ROLE_PRIORITY[role], { role, authMode: {}, unavailable: { [agent]: true } });
492
- const retry = sig.retryAfterSec ? ` (retry ~${sig.retryAfterSec}s)` : '';
493
- console.error(`⚠ ${agent} looks ${sig.kind}${retry} — consider failover${alt ? ` to ${alt}` : ' (no alternative available)'}.`);
494
- }
495
- // Ingest (F-LOOP-4 tie-in): record the result so the capsule + other agents (via read_memory
496
- // scope 'delegation') see what the delegated agent produced. Only a short snippet is stored —
497
- // the full output stays on the terminal (commit-forbidden-data caution, PRD §12).
498
- const snippet = stdout.trim().split('\n').filter(Boolean).slice(0, 3).join(' ').slice(0, 200);
499
- store.setMemory('delegation', 'last', { role, agent, ok, snippet, ts: new Date().toISOString() });
500
- store.appendLedger(ok ? 'delegated' : 'delegate-fail', `${role}:${agent}`);
501
- if (!ok)
502
- process.exitCode = res.status ?? 1;
503
- }
504
- function cmdAudit() {
505
- withStore((store) => {
506
- const signals = detectThrash(store.listLedger());
507
- if (signals.length === 0) {
508
- console.log('No anomaly signals. (audit is blocker-only by default — ADR-0005)');
509
- return;
510
- }
511
- console.log('Audit signals (consider pulling in the reviewer):');
512
- for (const s of signals)
513
- console.log(` - [${s.kind}] ${s.message}`);
514
- });
515
- }
516
- function cmdLedger(args) {
517
- withStore((store) => {
518
- const sub = args[0];
519
- if (sub === 'add') {
520
- const kind = args[1];
521
- if (!kind)
522
- fail('Usage: framein ledger add <kind> [target] [detail]');
523
- store.appendLedger(kind, args[2] ?? '', args.slice(3).join(' '));
524
- console.log(`Ledger += ${kind}${args[2] ? ' ' + args[2] : ''}`);
525
- }
526
- else if (sub === undefined || sub === 'list') {
527
- const entries = store.listLedger(50);
528
- if (entries.length === 0)
529
- console.log(' (ledger empty)');
530
- for (const e of entries)
531
- console.log(` ${e.kind}${e.target ? ' ' + e.target : ''}`);
532
- }
533
- else {
534
- fail(`Unknown 'ledger' subcommand '${sub}'. Use: add | list`);
535
- }
536
- });
537
- }
538
- async function serveMcp() {
539
- const store = openStore();
540
- try {
541
- await serve(store);
542
- }
543
- catch (e) {
544
- console.error(String(e));
545
- process.exitCode = 1;
546
- }
547
- finally {
548
- store.close();
549
- }
550
- }
551
- function cmdMcp(args) {
552
- if (args[0] === 'serve') {
553
- void serveMcp();
554
- return;
555
- }
556
- if (args[0] === 'register') {
557
- const rest = args.slice(1);
558
- const write = rest.includes('--write');
559
- const target = rest.find((a) => !a.startsWith('--')) ?? '.mcp.json';
560
- const isToml = target.endsWith('.toml');
561
- const existing = existsSync(target) ? readFileSync(target, 'utf8') : null;
562
- // Use the canonical `framein` bin if installed; else `node <this cli.js>` so the agent can
563
- // actually spawn the server (dev / not-globally-installed). The agent runs this verbatim.
564
- const probe = spawnSync('framein', ['--version'], { encoding: 'utf8', shell: true });
565
- const frameOnPath = probe.status === 0 && /framein/i.test((probe.stdout ?? '') + (probe.stderr ?? ''));
566
- const entry = resolveFrameinEntry(frameOnPath, process.argv[1] ?? 'dist/cli.js');
567
- const merged = isToml ? applyCodexMcp(existing, 'framein', entry) : applyJsonMcp(existing, 'framein', entry);
568
- console.log(`# server command: ${entry.command} ${entry.args.join(' ')} (${frameOnPath ? 'framein on PATH' : 'node fallback — framein not globally installed'})`);
569
- if (write) {
570
- writeFileSync(target, merged);
571
- console.log(`Registered framein in ${target} (${isToml ? 'TOML' : 'JSON'} merge — existing content preserved).`);
572
- console.log('Verify the live connection: `claude mcp list` and look for framein (codex/gemini have equivalents).');
573
- }
574
- else {
575
- console.log(`# preview — would merge framein into ${target} (${isToml ? 'TOML' : 'JSON'}). Re-run with --write to apply:`);
576
- console.log(merged);
577
- }
578
- return;
579
- }
580
- if (args[0] === 'patch') {
581
- const reg = frameinMcpRegistration();
582
- console.log("# Register framein's MCP server with each CLI (apply after review — framein won't write these):");
583
- console.log('\n## Claudemerge into .mcp.json:\n' + reg.claude);
584
- console.log('\n## Codexadd to ~/.codex/config.toml:\n' + reg.codex);
585
- console.log('\n## Geminimerge into settings.json:\n' + reg.gemini);
586
- console.log('\n# NOTE: `framein mcp serve` speaks the MCP stdio transport (newline-delimited JSON-RPC,');
587
- console.log('# which IS MCP stdio not LSP Content-Length framing). Applying these patches and');
588
- console.log('# verifying the live connection is the orchestration layer (B; ADR-0006/0007).');
589
- return;
590
- }
591
- const servers = detectMcpFromDisk();
592
- if (servers.length === 0) {
593
- console.log('No existing MCP servers detected (.mcp.json, ~/.codex/config.toml, settings.json).');
594
- }
595
- else {
596
- for (const s of servers)
597
- console.log(` [${s.agent}] ${s.name}${s.command ? ` -> ${s.command}` : ''}`);
598
- const conflicts = findConflicts(servers);
599
- if (conflicts.length)
600
- console.log(` conflicts (same name across agents): ${conflicts.join(', ')}`);
601
- }
602
- console.log(' (detected, not proxied — framein never relays your MCP servers)');
603
- }
604
- function cmdSkills() {
605
- console.log('Framein skills:');
606
- for (const s of FRAMEIN_SKILLS)
607
- console.log(` [framein] ${s.name} ${s.description}`);
608
- const detected = detectSkillsFromDisk();
609
- if (detected.length) {
610
- console.log('Detected (reused from your CLIs):');
611
- for (const s of detected)
612
- console.log(` [${s.source}] ${s.name}${s.description ? ` — ${s.description}` : ''}`);
613
- }
614
- console.log(' (catalog + recommend only — skills are not cross-executed across agents)');
615
- }
616
- function readVersion() {
617
- // SEA build bakes the version into a global (no package.json sits next to the executable).
618
- const baked = globalThis.__FRAMEIN_VERSION__;
619
- if (baked)
620
- return `framein ${baked}`;
621
- try {
622
- const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
623
- return `framein ${pkg.version ?? '?'}`;
624
- }
625
- catch {
626
- return 'framein (version unknown)';
627
- }
628
- }
629
- const USAGE = {
630
- init: 'framein init — initialize .frame/store.db and project native files',
631
- status: 'framein statusshow roles, lock, decision count',
632
- role: 'framein role set <role> <agent> | framein role list',
633
- adr: 'framein adr add <title> | supersede <id> <title> | show <id> | list',
634
- sync: 'framein sync [--dry-run] re-project native files from the store',
635
- unlock: 'framein unlock [scope] — release a stale write lock (default: global)',
636
- mcp: 'framein mcp [patch|register|serve] — detected servers / print patches / apply framein registration / run the server',
637
- skills: 'framein skillslist framein + detected (reused) skills',
638
- ask: 'framein ask <role> [prompt] [--show|--run|--interactive] [--trust [--ttl <dur>]] preview/record/run a headless delegation, or --interactive to drive the agent TUI; --trust adds bypass flags',
639
- audit: 'framein auditreport anomaly/thrash signals from the ledger (blocker-only)',
640
- ledger: 'framein ledger add <kind> [target] | framein ledger list',
641
- export: `framein export [path] write the git-canonical snapshot (default ${SNAPSHOT_PATH})`,
642
- import: `framein import [path] — rebuild the store from a snapshot (default ${SNAPSHOT_PATH})`,
643
- trust: 'framein trust <agent> [--ttl <dur>] — preview the per-agent permission-bypass flags + time-box (does NOT auto-enable)',
644
- lobby: 'framein lobby — optional interactive switchboard (also opens when you run bare `framein` in a terminal): run verbs inline, /lead <agent> to switch, /go to hand the terminal to the lead native TUI (framein pauses; resumes on exit). Zero-dep; simultaneous overlay needs node-pty (optional, ADR-0010)',
645
- shell: 'framein shellalias for `framein lobby`, kept for back-compat (not shown in the command list).',
646
- integrations: 'framein integrations list | show | install | uninstall <claude|codex|gemini|all> [--write] — generate logic-less /fr:* (Claude/Gemini) + $fr-* (Codex skill) wrappers that call `framein <verb> --json`',
647
- doctor: 'framein doctordetect agent CLIs on PATH + count installed wrappers',
648
- setup: 'framein setupdoctor + a wrapper-install recommendation for detected CLIs',
649
- task: 'framein task start <goal> | show | amend <goal|preserve|acceptance|protected|nongoal> <value> — the Task Contract (what "done" means)',
650
- start: 'framein start <goal> — start a Task Contract (alias of `framein task start`)',
651
- verify: 'framein verifyrun build/test validation and check it against the Task Contract (informational)',
652
- ship: 'framein shipthe enforced Validation Gate: READY/WARNING summary + commit/deploy guidance (exit 1 if hard validation fails)',
653
- risk: 'framein riskBlast Radius Guard: assess changed files for sensitive code (auth/payment/migration/secrets/deploy/deps) + required gates',
654
- route: 'framein route explain [role] show which agent would take a role in THIS repo and why (repo-local routing)',
655
- stats: 'framein statsrepo-local agent performance derived from the ledger',
656
- recipe: 'framein recipe list | show <name> | compile <name> <agent> vendor-neutral task protocols compiled to each CLI',
657
- rescue: 'framein rescue [--run] if the ledger shows a repair loop, surface signals + 3 options (never auto-acts); --run asks the reviewer to diagnose (read-only)',
658
- checkpoint: 'framein checkpoint [label] — record the current git commit as a known-good (green) state',
659
- rewind: 'framein rewind [--force] — preview (or with --force, execute) git reset to the last checkpoint',
660
- pause: 'framein pausesave an auto-generated Task Capsule (resume state) from the store + git',
661
- resume: 'framein resumeprint the saved capsule (or rebuild one) to continue without a manual handoff',
662
- capsule: 'framein capsule show render the current Task Capsule',
663
- challenge: 'framein challenge "<proposal>" [--run] | --block "<claim>" [--require "<change>"] | --accept | --show — bounded reviewer debate; --run asks the reviewer for a structured verdict',
664
- decide: 'framein decide accept|reject [text] — the lead resolves the open debate',
665
- debt: 'framein debt Vibe Debt Delta: what THIS change added (deps/TODOs/lines), not the whole codebase',
666
- explain: 'framein explain [--run] Ownership Brief skeleton (changed/test/rollback filled); --run has the explainer agent complete the narrative',
667
- };
668
- // --- Validation Gate helpers (F-LOOP-2): run the project's own build/test and collect results ---
669
- function pkgHasScript(name) {
670
- try {
671
- const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
672
- return typeof pkg.scripts?.[name] === 'string';
673
- }
674
- catch {
675
- return false;
676
- }
677
- }
678
- function runShell(command) {
679
- // commands are fixed strings (no user input); shell:true resolves npm.cmd/git on Windows.
680
- const res = spawnSync(command, { encoding: 'utf8', shell: true });
681
- if (res.error)
682
- return { exitCode: -1, output: res.error.message };
683
- return { exitCode: res.status ?? -1, output: (res.stdout ?? '') + (res.stderr ?? '') };
684
- }
685
- function gitDiff() {
686
- const res = spawnSync('git', ['diff', 'HEAD'], { encoding: 'utf8' });
687
- return res.status === 0 ? (res.stdout ?? '') : '';
688
- }
689
- function gitChangedFiles() {
690
- const isRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' });
691
- if (isRepo.status !== 0)
692
- return [];
693
- const hasHead = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], { encoding: 'utf8' }).status === 0;
694
- const commands = hasHead
695
- ? [['diff', '--name-only', 'HEAD', '--'], ['ls-files', '--others', '--exclude-standard']]
696
- : [['ls-files', '--cached', '--others', '--exclude-standard']];
697
- const files = new Set();
698
- for (const args of commands) {
699
- const res = spawnSync('git', args, { encoding: 'utf8' });
700
- if (res.status !== 0)
701
- continue;
702
- for (const line of (res.stdout ?? '').split('\n')) {
703
- const file = line.trim();
704
- if (file)
705
- files.add(file.replace(/\\/g, '/'));
706
- }
707
- }
708
- return [...files].sort();
709
- }
710
- function collectEvidence() {
711
- const bundle = {};
712
- if (pkgHasScript('build')) {
713
- const r = runShell('npm run build');
714
- bundle.build = { command: 'npm run build', exitCode: r.exitCode };
715
- }
716
- if (pkgHasScript('test')) {
717
- const r = runShell('npm test');
718
- bundle.tests = { command: 'npm test', summary: parseTestSummary(r.output), exitCode: r.exitCode };
719
- }
720
- const changed = gitChangedFiles();
721
- if (changed.length)
722
- bundle.changedFiles = changed;
723
- return bundle;
724
- }
725
- function cmdVerify(args = []) {
726
- withStore((store) => {
727
- withCliWriteLock(store, 'evidence', () => {
728
- const bundle = collectEvidence();
729
- store.setMemory('evidence', 'last', bundle); // recorded so `ship`/capsule can reuse it
730
- const result = gate(store.getTaskContract(), bundle);
731
- if (wantsJson(args)) {
732
- emitJson('verify', { ready: result.ready, status: result.ready ? 'ready' : 'not_ready', checks: result.checks, warnings: result.warnings });
733
- return;
734
- }
735
- console.log(renderGate(result, cliUi()));
736
- });
737
- });
738
- }
739
- function cmdShip(args = []) {
740
- withStore((store) => {
741
- withCliWriteLock(store, 'evidence', () => {
742
- const bundle = collectEvidence();
743
- store.setMemory('evidence', 'last', bundle);
744
- const result = gate(store.getTaskContract(), bundle);
745
- // Blast Radius Guard (F-LOOP-6): raise the gate when the change touches sensitive code.
746
- const blast = assessBlastRadius(bundle.changedFiles ?? []);
747
- if (wantsJson(args)) {
748
- emitJson('ship', {
749
- ready: result.ready, status: result.ready ? 'ready' : 'not_ready',
750
- checks: result.checks, warnings: result.warnings,
751
- safeToCommit: result.ready, safeToDeploy: result.ready ? 'requires_human' : false,
752
- risk: blast.level, requiredGates: blast.requiredGates,
753
- });
754
- store.setMemory('risk', 'last', blast.level);
755
- if (!result.ready)
756
- process.exitCode = 1;
757
- return;
758
- }
759
- console.log(renderShip(result, cliUi()));
760
- if (blast.level !== 'low') {
761
- console.log('\n' + renderBlast(blast, cliUi()));
762
- const t = riskTransition(store.getMemory('risk', 'last'), blast.level);
763
- if (t)
764
- console.log(t);
765
- }
766
- store.setMemory('risk', 'last', blast.level);
767
- if (!result.ready)
768
- process.exitCode = 1; // ship is the enforced gate; verify is informational
769
- });
770
- });
771
- }
772
- // --- Repo-local Routing (F-LOOP-7): route by this repo's results, and explain the choice ---
773
- function cmdRoute(args) {
774
- if (args[0] !== 'explain')
775
- fail('Usage: framein route explain [role]');
776
- const roleArg = args.slice(1).find((a) => !a.startsWith('--'));
777
- if (roleArg && !isRole(roleArg))
778
- fail(`Unknown role '${roleArg}'. Valid: ${ROLES.join(', ')}`);
779
- const role = roleArg ?? 'reviewer';
780
- withStore((store) => {
781
- const e = explainRoute(role, { authMode: {} }, computeRepoStats(store.listLedger()));
782
- if (wantsJson(args)) {
783
- emitJson('route', { role: e.role, agent: e.agent, reasons: e.reasons, alternative: e.alternative ?? null });
784
- return;
785
- }
786
- console.log(renderRouteExplain(e, cliUi()));
787
- });
788
- }
789
- function cmdStats(args = []) {
790
- withStore((store) => {
791
- const stats = computeRepoStats(store.listLedger());
792
- if (wantsJson(args)) {
793
- emitJson('stats', { stats });
794
- return;
795
- }
796
- console.log(renderStats(stats, cliUi()));
797
- });
798
- }
799
- // --- Vibe Debt Delta + Ownership Brief (F-LOOP-9/10) ---
800
- function cmdDebt(args = []) {
801
- const d = parseDiffDebt(gitDiff());
802
- if (wantsJson(args)) {
803
- emitJson('debt', { addedLines: d.addedLines, removedLines: d.removedLines, addedDeps: d.addedDeps, todos: d.todos });
804
- return;
805
- }
806
- console.log(renderDebt(d, cliUi()));
807
- }
808
- function cmdExplain(args = []) {
809
- withStore((store) => {
810
- const cp = store.getMemory('checkpoint', 'last');
811
- const brief = {
812
- goal: store.getTaskContract()?.goal,
813
- changedFiles: gitChangedFiles(),
814
- testCommand: pkgHasScript('test') ? 'npm test' : undefined,
815
- lastGreen: cp?.sha,
816
- };
817
- const skeleton = ownershipBrief(brief);
818
- if (!args.includes('--run')) {
819
- if (wantsJson(args)) {
820
- emitJson('explain', { ...brief, skeleton });
821
- return;
822
- }
823
- console.log(skeleton);
824
- console.log('\n(the narrative sections are the explainer role’s live job — run `framein explain --run`)');
825
- return;
826
- }
827
- const explainer = store.getRole('explainer') ?? 'gemini'; // live: explainer fills the narrative
828
- console.log(`Asking ${explainer} to complete the ownership brief…\n`);
829
- const r = spawnAgentText(explainer, `You are the explainer. Complete the sections marked "(for the explainer role to fill)" in this ownership brief, based on the repository. Return the full completed brief.\n\n${skeleton}`);
830
- if (wantsJson(args)) {
831
- emitJson('explain', { ...brief, agent: explainer, ok: r.ok, text: r.text || skeleton });
832
- return;
833
- }
834
- console.log(r.text || skeleton);
835
- if (r.ok && r.text)
836
- store.setMemory('brief', 'last', { agent: explainer, ts: new Date().toISOString() });
837
- });
838
- }
839
- // --- Frame Recipe (F-LOOP-8): vendor-neutral protocols, compiled to each CLI (static, no store) ---
840
- function cmdRecipe(args) {
841
- const sub = args[0] ?? 'list';
842
- if (sub === 'list') {
843
- console.log('Recipes (vendor-neutral task protocols, compiled to each CLI — not cross-executed):');
844
- for (const r of listRecipes())
845
- console.log(` ${r.name} — trigger: ${r.trigger}, ${r.steps.length} steps`);
846
- return;
847
- }
848
- if (sub === 'show') {
849
- const r = getRecipe(args[1] ?? '');
850
- if (!r)
851
- fail(`Unknown recipe '${args[1] ?? ''}'. Try: framein recipe list`);
852
- console.log(renderRecipe(r, cliUi()));
853
- return;
854
- }
855
- if (sub === 'compile') {
856
- const r = getRecipe(args[1] ?? '');
857
- if (!r)
858
- fail(`Unknown recipe '${args[1] ?? ''}'. Try: framein recipe list`);
859
- const agent = args[2];
860
- if (!agent || !isAgent(agent))
861
- fail(`Usage: framein recipe compile <name> <${AGENTS.join('|')}>`);
862
- console.log(compileRecipe(r, agent));
863
- return;
864
- }
865
- fail(`Unknown 'recipe' subcommand '${sub}'. Use: list | show <name> | compile <name> <agent>`);
866
- }
867
- function cmdRisk(args = []) {
868
- withStore((store) => {
869
- const a = assessBlastRadius(gitChangedFiles());
870
- const prev = store.getMemory('risk', 'last');
871
- store.setMemory('risk', 'last', a.level);
872
- if (wantsJson(args)) {
873
- emitJson('risk', { level: a.level, hits: a.hits, requiredGates: a.requiredGates });
874
- return;
875
- }
876
- console.log(renderBlast(a, cliUi()));
877
- const t = riskTransition(prev, a.level);
878
- if (t)
879
- console.log(t);
880
- });
881
- }
882
- // --- Rescue Mode + checkpoints (F-LOOP-3) ---
883
- function gitHead() {
884
- const res = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' });
885
- return res.status === 0 && res.stdout ? res.stdout.trim() : null;
886
- }
887
- /** Spawn an agent headlessly and return its free-text output (rescue diagnosis, ownership brief).
888
- * Same shell+stdin model as runDelegated (prompt off argv). Needs the real CLI + its trust flag
889
- * for any tool use; plain text generation works without it. */
890
- function spawnAgentText(agent, prompt) {
891
- const inv = buildInvocation(agent, prompt);
892
- const res = spawnSync(invocationCommand(inv), { input: inv.stdin, encoding: 'utf8', shell: true });
893
- return { ok: res.status === 0 && !res.error, text: (res.stdout ?? '').trim() || (res.stderr ?? '').trim() };
894
- }
895
- function cmdRescue(args = []) {
896
- withStore((store) => {
897
- const signals = detectThrash(store.listLedger());
898
- const cp = store.getMemory('checkpoint', 'last');
899
- const reviewer = store.getRole('reviewer');
900
- const report = buildRescue(signals, { lastGreen: cp, reviewer });
901
- if (wantsJson(args)) {
902
- emitJson('rescue', { triggered: report.triggered, signals: report.signals, lastGreen: report.lastGreen ?? null, options: report.options });
903
- return;
904
- }
905
- console.log(renderRescue(report, cliUi()));
906
- if (args.includes('--run') && report.triggered) { // option A, live: reviewer diagnoses (no edits)
907
- const rev = reviewer ?? 'codex';
908
- const ctx = signals.map((s) => `- ${s.message}`).join('\n');
909
- console.log(`\nAsking ${rev} to diagnose (read-only)…`);
910
- const r = spawnAgentText(rev, `You are the reviewer diagnosing a repair loop. Do NOT edit code. Signals:\n${ctx}\n\nGive a short likely root cause and the single next action. Be terse.`);
911
- console.log(r.text || '(no diagnosis returned)');
912
- if (r.ok && r.text)
913
- store.setMemory('rescue', 'last', { agent: rev, diagnosis: r.text.slice(0, 500), ts: new Date().toISOString() });
914
- }
915
- });
916
- }
917
- function cmdCheckpoint(args) {
918
- const label = args.filter((a) => !a.startsWith('--')).join(' ').trim();
919
- const sha = gitHead();
920
- if (!sha)
921
- fail('Not a git repo (or no commits). `framein checkpoint` records the current commit as a known-good state.');
922
- withStore((store) => {
923
- store.setMemory('checkpoint', 'last', { sha, label });
924
- store.appendLedger('checkpoint', sha.slice(0, 7), label);
925
- console.log(`Checkpoint recorded: ${sha.slice(0, 7)}${label ? ` (${label})` : ''}. Return here with \`framein rewind\`.`);
926
- });
927
- }
928
- function cmdRewind(args) {
929
- const force = args.includes('--force');
930
- withStore((store) => {
931
- const cp = store.getMemory('checkpoint', 'last');
932
- if (!cp)
933
- fail('No checkpoint recorded. Run `framein checkpoint` at a known-good state first.');
934
- if (!force) {
935
- console.log(`would rewind to ${cp.sha.slice(0, 7)}${cp.label ? ` (${cp.label})` : ''}:`);
936
- console.log(` git reset --hard ${cp.sha}`);
937
- console.log(' destructive — discards uncommitted changes. Re-run with --force to execute.');
938
- return;
939
- }
940
- const res = spawnSync('git', ['reset', '--hard', cp.sha], { encoding: 'utf8' });
941
- if (res.status !== 0)
942
- fail(`git reset failed: ${(res.stderr ?? '').trim()}`);
943
- store.appendLedger('rewind', cp.sha.slice(0, 7), cp.label ?? '');
944
- console.log(`Rewound to ${cp.sha.slice(0, 7)}.`);
945
- });
946
- }
947
- // --- Task Capsule (F-LOOP-4): assemble a resume capsule from what the store + git already hold ---
948
- function gitBranch() {
949
- const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' });
950
- return res.status === 0 && res.stdout ? res.stdout.trim() : undefined;
951
- }
952
- function gatherCapsule(store) {
953
- const contract = store.getTaskContract();
954
- const decisions = store.listAdrs().filter((a) => !store.isSuperseded(a.id)).slice(-5).map((a) => ({ id: a.id, title: a.title }));
955
- const cp = store.getMemory('checkpoint', 'last');
956
- const ev = store.getMemory('evidence', 'last');
957
- const del = store.getMemory('delegation', 'last');
958
- const handoff = store.getMemory('handoff', 'next');
959
- const changed = gitChangedFiles();
960
- return buildCapsule({
961
- goal: contract?.goal,
962
- decisions,
963
- branch: gitBranch(),
964
- lastGreen: cp?.sha,
965
- changedFiles: changed,
966
- testSummary: ev?.tests?.summary ?? null,
967
- ledger: store.listLedger(),
968
- lastDelegation: del ? { agent: del.agent, ok: del.ok } : undefined,
969
- handoffTarget: handoff && isAgent(handoff) ? handoff : undefined,
970
- });
971
- }
972
- function cmdPause() {
973
- withStore((store) => {
974
- const c = gatherCapsule(store);
975
- store.setMemory('capsule', 'last', c);
976
- console.log('Paused — task capsule saved (resume with `framein resume`):\n');
977
- console.log(renderCapsule(c, cliUi()));
978
- });
979
- }
980
- function cmdResume() {
981
- withStore((store) => {
982
- const saved = store.getMemory('capsule', 'last');
983
- const c = saved ?? gatherCapsule(store);
984
- console.log(saved ? 'Resuming from the saved capsule:\n' : 'No saved capsule — rebuilt fresh from the store:\n');
985
- console.log(renderCapsule(c, cliUi()));
986
- console.log('\nRead this + the repo to continue. No manual handoff needed.');
987
- });
988
- }
989
- function cmdCapsule(args) {
990
- const sub = args[0] ?? 'show';
991
- if (sub === 'show') {
992
- withStore((store) => console.log(renderCapsule(gatherCapsule(store), cliUi())));
993
- return;
994
- }
995
- // `capsule <agent>` arms a handoff: carry the capsule to that model. Exiting the current agent triggers
996
- // the switch (launchLeadTui consumes it); framein never pushes into a TUI (ADR-0009) — the next lead pulls.
997
- if (isAgent(sub)) {
998
- withStore((store) => { store.setMemory('handoff', 'next', sub); store.appendLedger('handoff', sub); });
999
- console.log(`↪ Handoff to ${sub} armed. Exit this agent (Ctrl-D) — framein switches to ${sub}, which loads the capsule.`);
1000
- return;
1001
- }
1002
- fail(`Unknown 'capsule' target '${sub}'. Use: capsule show | capsule <${AGENTS.join('|')}>`);
1003
- }
1004
- // --- Native command wrappers (ADR-0010/0011): generate/install/remove /fr:* + $fr-* shims ---
1005
- const WRAP_HOSTS = ['claude', 'codex', 'gemini'];
1006
- function resolveHosts(arg) {
1007
- if (!arg || arg === 'all' || arg.startsWith('--'))
1008
- return WRAP_HOSTS;
1009
- if (!WRAP_HOSTS.includes(arg))
1010
- fail(`Unknown host '${arg}'. Use: ${WRAP_HOSTS.join(' | ')} | all`);
1011
- return [arg];
1012
- }
1013
- function cliInstalled(c) {
1014
- const r = spawnSync(c, ['--version'], { encoding: 'utf8', shell: true });
1015
- return r.status === 0 && !r.error;
1016
- }
1017
- function cmdIntegrations(args) {
1018
- const sub = args[0] ?? 'list';
1019
- if (sub === 'list') {
1020
- console.log(`framein wrappers namespace 'fr', verbs: ${WRAP_VERBS.map((v) => v.verb).join(', ')}`);
1021
- for (const h of WRAP_HOSTS) {
1022
- const present = wrapperFiles(h).filter((f) => existsSync(f.path)).length;
1023
- const pattern = h === 'codex'
1024
- ? '.codex/skills/fr-<verb>/SKILL.md'
1025
- : wrapperFiles(h)[0].path.replace(/[^/]+$/, '*');
1026
- console.log(` ${h.padEnd(7)} ${pattern} (${present}/${WRAP_VERBS.length} installed)`);
1027
- }
1028
- console.log('Install: framein integrations install <claude|codex|gemini|all> --write');
1029
- return;
1030
- }
1031
- const hosts = resolveHosts(args[1]);
1032
- if (sub === 'show') {
1033
- for (const h of hosts)
1034
- for (const f of wrapperFiles(h, WRAPPER_BIN)) {
1035
- console.log(`# ${f.path}`);
1036
- console.log(f.content);
1037
- }
1038
- return;
1039
- }
1040
- if (sub === 'install') {
1041
- const write = args.includes('--write');
1042
- let n = 0;
1043
- for (const h of hosts)
1044
- for (const f of wrapperFiles(h, WRAPPER_BIN)) {
1045
- if (write) {
1046
- mkdirSync(dirname(f.path), { recursive: true });
1047
- writeFileSync(f.path, f.content);
1048
- console.log(`wrote ${f.path}`);
1049
- }
1050
- else
1051
- console.log(`would write ${f.path}`);
1052
- n++;
1053
- }
1054
- console.log(write
1055
- ? `Installed ${n} wrapper(s). Try /fr:verify (Claude/Gemini) or $fr-verify (Codex).`
1056
- : `# preview of ${n} file(s) re-run with --write. Wrappers are logic-less: they call \`framein <verb> --json\`.`);
1057
- return;
1058
- }
1059
- if (sub === 'uninstall') {
1060
- let n = 0;
1061
- for (const h of hosts)
1062
- for (const f of wrapperFiles(h)) {
1063
- if (existsSync(f.path) && readFileSync(f.path, 'utf8').includes(PROVENANCE)) {
1064
- rmSync(f.path);
1065
- console.log(`removed ${f.path}`);
1066
- n++;
1067
- }
1068
- }
1069
- console.log(`Removed ${n} framein wrapper(s).`);
1070
- return;
1071
- }
1072
- fail(`Unknown 'integrations' subcommand '${sub}'. Use: list | show | install | uninstall`);
1073
- }
1074
- function cmdDoctor() {
1075
- console.log('framein doctor — environment check');
1076
- for (const c of WRAP_HOSTS)
1077
- console.log(` CLI ${c.padEnd(7)} ${cliInstalled(c) ? 'installed' : 'not found'}`);
1078
- for (const h of WRAP_HOSTS) {
1079
- const present = wrapperFiles(h).filter((f) => existsSync(f.path)).length;
1080
- console.log(` wrappers ${h.padEnd(7)} ${present}/${WRAP_VERBS.length} (framein integrations install ${h} --write)`);
1081
- }
1082
- console.log(' note: Codex wrappers are skills in .codex/skills/fr-<verb>/ — invoke them with `$fr-verify` (Codex `/prompts` is deprecated).');
1083
- console.log(" note: bare-name clashes are avoided by the 'fr' namespace (/fr:verify, $fr-verify).");
1084
- }
1085
- function cmdSetup() {
1086
- cmdDoctor();
1087
- const detected = WRAP_HOSTS.filter(cliInstalled);
1088
- const missing = detected.filter((h) => wrapperFiles(h).some((f) => !existsSync(f.path)));
1089
- console.log('');
1090
- if (detected.length === 0)
1091
- console.log('No agent CLI detected on PATH. Install claude/codex/gemini, then: framein integrations install all --write');
1092
- else if (missing.length === 0)
1093
- console.log('All detected agent wrappers are installed. Use /fr:verify (Claude/Gemini) or $fr-verify (Codex) inside your agent.');
1094
- else
1095
- console.log(`Next: framein integrations install ${missing.join(' ')} --write (then use /fr:verify in your agent)`);
1096
- }
1097
- // --- Optional interactive `framein` lobby (ADR-0010, layer 4): zero-dep readline switchboard ---
1098
- /** Is the agent's CLI reachable on PATH? Preflight before a /go hand-over (avoids a confusing failure). */
1099
- function agentAvailable(agent) {
1100
- const r = spawnSync(interactiveCommand(agent), ['--version'], { encoding: 'utf8', shell: true });
1101
- return r.status === 0 && !r.error;
1102
- }
1103
- function launchLeadTui(state, agent, interactive, prompt, caps, pause, resume) {
1104
- const ui = painter(caps);
1105
- state.lead = agent;
1106
- if (prompt)
1107
- console.log(ui.tone(`(note: a prompt isn't seeded into the native TUI — paste it once ${agent} opens)`, 'muted'));
1108
- if (!interactive) {
1109
- console.log(ui.tone(`(skipped launching ${agent}: the lobby needs a TTY to hand over the native UI)`, 'muted'));
1110
- return;
1111
- }
1112
- // Preflight: don't hand the terminal to a CLI that isn't installed (would fail confusingly under inherit).
1113
- if (!agentAvailable(agent)) {
1114
- const install = {
1115
- claude: 'claude.ai/code',
1116
- codex: 'npm i -g @openai/codex',
1117
- gemini: 'npm i -g @google/gemini-cli (then set GEMINI_API_KEY)',
1118
- };
1119
- console.error(ui.tone(`${agent} not found on PATH. Install its CLI first: ${install[agent]}`, 'danger'));
1120
- console.error(ui.tone(`framein drives the ${agent} CLI — your API key / login lives in that CLI, framein never handles it.`, 'muted'));
1121
- return;
1122
- }
1123
- // Context card + enter event: carry intent INTO the native UI; we surface state, never scrape the TUI (ADR-0009).
1124
- let entered = false;
1125
- let resumeSession = false; // re-entry resume the agent's own last session (continuity)
1126
- if (existsSync(DB_PATH)) {
1127
- try {
1128
- const store = Store.open(DB_PATH);
1129
- try {
1130
- // Have we handed off to THIS agent here before? Then continue its session instead of starting fresh.
1131
- resumeSession = store.listLedger().some((e) => (e.kind === 'enter' || e.kind === 'return') && e.target === agent);
1132
- const cp = store.getMemory('checkpoint', 'last');
1133
- const rows = handoffCardRows({
1134
- lead: agent,
1135
- goal: store.getTaskContract()?.goal,
1136
- reviewer: store.getRole('reviewer') ?? undefined,
1137
- lastGreen: cp ? `${cp.sha.slice(0, 7)}${cp.label ? ` (${cp.label})` : ''}` : undefined,
1138
- blocker: detectThrash(store.listLedger())[0]?.message,
1139
- });
1140
- console.log(renderFrame(`HANDOFF → ${agent}`, ['Intent in · Validation in · Drift out'], { ui, unicode: caps.unicode, columns: caps.columns }));
1141
- console.log(renderKeyVals(rows, ui));
1142
- store.appendLedger('enter', agent);
1143
- entered = true;
1144
- }
1145
- finally {
1146
- store.close();
1147
- }
1148
- }
1149
- catch { /* no/unreadable store just hand over */ }
1150
- }
1151
- const trusted = !!state.trustUntil && state.trustUntil > Date.now();
1152
- const trustFlags = trusted ? trustPlan(agent).flags : [];
1153
- console.log(ui.tone(`→ handing the terminal to ${agent} — framein is paused.`, 'brand'));
1154
- if (resumeSession)
1155
- console.log(ui.tone(` ↻ resuming your previous ${agent} session (continuity).`, 'muted'));
1156
- if (trusted)
1157
- console.log(ui.tone(` ⚠ trust ON — ${agent} runs WITHOUT approval prompts (${trustFlags.join(' ')}).`, 'danger'));
1158
- console.log(ui.tone(` to come back to the lobby, exit ${agent} (Ctrl-D). ('/go' is a lobby command it won't return you.)`, 'muted'));
1159
- pause();
1160
- // trustFlags are placed per-agent by interactiveCommand (codex needs them BEFORE its `resume` subcommand).
1161
- const res = spawnSync(interactiveCommand(agent, resumeSession, trustFlags), { stdio: 'inherit', shell: true });
1162
- resume();
1163
- if (res.error)
1164
- console.error(ui.tone(`Could not launch ${agent}: ${res.error.message}`, 'danger'));
1165
- // Return event + recap + auto-handoff: if the agent armed `capsule <next>` before exiting, switch to it.
1166
- let nextLead;
1167
- if (entered && existsSync(DB_PATH)) {
1168
- try {
1169
- const s = Store.open(DB_PATH);
1170
- try {
1171
- s.appendLedger('return', agent);
1172
- const pend = s.getMemory('handoff', 'next');
1173
- if (pend) {
1174
- s.deleteMemory('handoff', 'next');
1175
- if (isAgent(pend) && pend !== agent)
1176
- nextLead = pend;
1177
- } // one-shot
1178
- }
1179
- finally {
1180
- s.close();
1181
- }
1182
- }
1183
- catch { /* ignore */ }
1184
- }
1185
- console.log(ui.tone(`← back in the framein lobby (lead: ${state.lead}). \`verify\` to re-check · \`status\` for state.`, 'muted'));
1186
- if (nextLead) {
1187
- console.log(ui.tone(`↪ handoff: switching to ${nextLead} (carrying the capsule)…`, 'brand'));
1188
- state.lead = nextLead;
1189
- launchLeadTui(state, nextLead, interactive, undefined, caps, pause, resume); // chain; the new lead pulls the capsule
1190
- }
1191
- }
1192
- /** The live status block shown on shell entry (style guide §8.1/§18): who leads, the contract, sync. */
1193
- function shellStatusRows(state) {
1194
- const branch = gitBranch();
1195
- const rows = [['project', `${basename(process.cwd())}${branch ? ` · ${branch}` : ''}`], ['lead', state.lead]];
1196
- if (existsSync(DB_PATH)) {
1197
- try {
1198
- const store = Store.open(DB_PATH);
1199
- try {
1200
- const rev = store.getRole('reviewer');
1201
- if (rev)
1202
- rows.push(['reviewer', rev]);
1203
- rows.push(['task', store.getTaskContract()?.goal ?? 'no active contract']);
1204
- }
1205
- finally {
1206
- store.close();
1207
- }
1208
- }
1209
- catch { /* show what we have */ }
1210
- }
1211
- else {
1212
- rows.push(['task', 'run `init` to start']);
1213
- }
1214
- return rows;
1215
- }
1216
- /** The lobby's starting lead = the store's `implementer` role (if set), else the routing default.
1217
- * Without this the fr(<lead>)› prompt and the status row would show a hardcoded 'claude' (ADR-E fix). */
1218
- function initialLead() {
1219
- if (existsSync(DB_PATH)) {
1220
- try {
1221
- const store = Store.open(DB_PATH);
1222
- try {
1223
- const impl = store.getRole('implementer');
1224
- if (impl && isAgent(impl))
1225
- return impl;
1226
- }
1227
- finally {
1228
- store.close();
1229
- }
1230
- }
1231
- catch { /* unreadable store — fall through */ }
1232
- }
1233
- return 'claude';
1234
- }
1235
- /** Generic zero-dep arrow-key picker: a raw-mode keypress loop driving the pure select.ts reducer.
1236
- * TTY only (callers guard). Restores raw mode + cursor on EVERY exit path including an unexpected
1237
- * process exit (the `exit` safety hook) so the terminal is never left dirty. Returns the chosen
1238
- * value, or null if cancelled (Esc / Ctrl-C). One primitive powers /lead and the first-run wizard. */
1239
- function promptSelect(header, items, caps, startIndex = 0, clearOnExit = false) {
1240
- const ui = painter(caps);
1241
- let state = startIndex > 0 ? { ...initSelect(items), index: startIndex } : initSelect(items);
1242
- const out = process.stdout;
1243
- const marker = caps.unicode ? '' : '>';
1244
- let drawn = 0;
1245
- const draw = (first) => {
1246
- const lines = renderSelectLines(header, state, marker);
1247
- if (!first) {
1248
- moveCursor(out, 0, -drawn);
1249
- clearScreenDown(out);
1250
- }
1251
- out.write(lines.map((l, i) => (i === 0 ? ui.tone(l, 'muted') : l)).join('\n') + '\n');
1252
- drawn = lines.length;
1253
- };
1254
- return new Promise((resolve) => {
1255
- const restore = () => { try {
1256
- if (process.stdin.isTTY)
1257
- process.stdin.setRawMode(false);
1258
- out.write('\x1b[?25h');
1259
- }
1260
- catch { /* best effort */ } };
1261
- emitKeypressEvents(process.stdin);
1262
- if (process.stdin.isTTY)
1263
- process.stdin.setRawMode(true);
1264
- process.stdin.resume();
1265
- out.write('\x1b[?25l'); // hide cursor (ANSI)
1266
- process.once('exit', restore); // safety net: never leave raw mode / a hidden cursor behind
1267
- draw(true);
1268
- const cleanup = (result) => {
1269
- process.stdin.off('keypress', onKey);
1270
- process.removeListener('exit', restore);
1271
- if (clearOnExit && drawn) {
1272
- moveCursor(out, 0, -drawn);
1273
- clearScreenDown(out);
1274
- } // erase the menu
1275
- restore();
1276
- resolve(result);
1277
- };
1278
- const onKey = (_str, key) => {
1279
- const step = reduceSelectKey(state, key);
1280
- if (step.kind === 'cancel')
1281
- return cleanup(null);
1282
- if (step.kind === 'accept')
1283
- return cleanup(step.value);
1284
- state = step.state;
1285
- draw(false);
1286
- };
1287
- process.stdin.on('keypress', onKey);
1288
- });
1289
- }
1290
- /** Interactive inline `/` command palette + line editor for the lobby (TTY only; the non-TTY path uses
1291
- * readline). Renders the prompt and the line you're typing; the moment the line starts with `/`, a
1292
- * filterable suggestion list appears BELOW it (palette.ts). ⏎ runs exactly what you typed — it never
1293
- * force-picks the top item; ↑/↓ opt into a suggestion and then ⏎ runs that one; Esc clears the line.
1294
- * Raw mode is entered only while editing and always restored before returning, so /go's child process
1295
- * inherits a clean cooked terminal and inter-command output is normal. Returns the line to run, or
1296
- * {exit} on Ctrl-D / double Ctrl-C. One keypress loop; the decision logic is the pure reducer. */
1297
- function readLobbyLine(state, caps) {
1298
- const ui = painter(caps);
1299
- const out = process.stdout;
1300
- const arrow = caps.unicode ? '' : '>';
1301
- const marker = caps.unicode ? '›' : '>';
1302
- const cols = caps.columns && caps.columns > 20 ? caps.columns : 80;
1303
- const promptPlain = `fr(${state.lead})${arrow} `;
1304
- const promptColored = `${ui.tone('fr', 'brand')}(${state.lead})${arrow} `;
1305
- let ps = initPalette('');
1306
- let below = 0; // lines drawn under the input line (suggestions + optional note)
1307
- let note = ''; // transient hint (e.g. Ctrl-C armed), cleared on the next keystroke
1308
- let sigintArmed = false;
1309
- return new Promise((resolve) => {
1310
- const restore = () => { try {
1311
- if (process.stdin.isTTY)
1312
- process.stdin.setRawMode(false);
1313
- }
1314
- catch { /* best effort */ } };
1315
- const fit = (s) => (s.length > cols - 1 ? `${s.slice(0, cols - 2)}…` : s);
1316
- // What the INPUT LINE shows. Filtering still keys off ps.buf (what you typed), but once you arrow
1317
- // into the list the line mirrors the highlighted command — so the prompt shows what you're picking.
1318
- const shownLine = () => {
1319
- if (!ps.navigated)
1320
- return ps.buf;
1321
- const sugg = paletteSuggestions(ps.buf, LOBBY_PALETTE);
1322
- return sugg.length ? sugg[Math.min(ps.index, sugg.length - 1)].cmd : ps.buf;
1323
- };
1324
- const draw = (first) => {
1325
- if (!first) {
1326
- out.write('\r');
1327
- clearScreenDown(out);
1328
- } // cursor is at end of input → col0 + wipe down
1329
- const shown = shownLine();
1330
- out.write(promptColored + shown); // input line; cursor now at its end
1331
- const plain = renderPaletteSuggestions(ps, LOBBY_PALETTE, marker).map(fit);
1332
- const idx = Math.min(ps.index, Math.max(0, plain.length - 1));
1333
- const lines = plain.map((ln, i) => ui.tone(ln, i === idx ? 'brand' : 'muted'));
1334
- if (note)
1335
- lines.push(ui.tone(fit(note), 'muted'));
1336
- for (const ln of lines)
1337
- out.write(`\n${ln}`);
1338
- below = lines.length;
1339
- if (below > 0) {
1340
- moveCursor(out, 0, -below);
1341
- out.write('\r');
1342
- moveCursor(out, promptPlain.length + shown.length, 0);
1343
- }
1344
- };
1345
- const finish = (result) => {
1346
- out.write('\r');
1347
- clearScreenDown(out); // erase the live menu
1348
- out.write(`${promptColored}${result.kind === 'line' ? result.line : shownLine()}\n`); // echo the actual command run
1349
- process.stdin.off('keypress', onKey);
1350
- process.removeListener('exit', restore);
1351
- restore();
1352
- process.stdin.pause();
1353
- resolve(result);
1354
- };
1355
- const onKey = (_str, key) => {
1356
- const step = reducePaletteKey(ps, key, LOBBY_PALETTE);
1357
- if (step.kind === 'submit')
1358
- return finish({ kind: 'line', line: step.line });
1359
- if (step.kind === 'exit')
1360
- return finish({ kind: 'exit' });
1361
- if (step.kind === 'sigint') {
1362
- if (ps.buf) {
1363
- ps = initPalette('');
1364
- note = '';
1365
- sigintArmed = false;
1366
- return draw(false);
1367
- } // first Ctrl-C clears the line
1368
- if (sigintArmed)
1369
- return finish({ kind: 'exit' });
1370
- sigintArmed = true;
1371
- note = "press Ctrl-C again (or /exit) to leave the lobby";
1372
- return draw(false);
1373
- }
1374
- ps = step.state;
1375
- note = '';
1376
- sigintArmed = false;
1377
- draw(false);
1378
- };
1379
- emitKeypressEvents(process.stdin);
1380
- if (process.stdin.isTTY)
1381
- process.stdin.setRawMode(true);
1382
- process.stdin.resume();
1383
- process.once('exit', restore);
1384
- process.stdin.on('keypress', onKey);
1385
- draw(true);
1386
- });
1387
- }
1388
- /** /lead picker: lead agents with install/current hints, starting on the current lead. */
1389
- function promptLeadSelect(current, caps) {
1390
- const items = AGENTS.map((a) => ({
1391
- value: a, label: a,
1392
- hint: a === current ? 'current' : (agentAvailable(a) ? 'installed' : 'not on PATH'),
1393
- }));
1394
- const ci = Math.max(0, items.findIndex((i) => i.value === current));
1395
- return promptSelect('pick a lead ↑↓ move · type to filter · enter select · esc cancel', items, caps, ci);
1396
- }
1397
- /** First-run mini-wizard (G): shown once, interactively, when there's no project here yet. Detects which
1398
- * agent CLIs are installed and lets the user pick a default lead (Esc to skip). Returns the chosen lead,
1399
- * or null to skip; the caller runs `init` + sets the implementer role. */
1400
- async function firstRunWizard(caps) {
1401
- const ui = painter(caps);
1402
- // No separate banner — the wizard flows straight into the one lobby banner (single-stage entry).
1403
- const installed = AGENTS.filter((a) => agentAvailable(a));
1404
- if (installed.length === 0) {
1405
- console.log(ui.tone('First run — no agent CLI found on PATH (claude / codex / gemini).', 'muted'));
1406
- console.log(ui.tone('You can still `init` now and add an agent later: claude → claude.ai/code · codex → npm i -g @openai/codex · gemini → npm i -g @google/gemini-cli', 'muted'));
1407
- return null;
1408
- }
1409
- const items = installed.map((a) => ({ value: a, label: a, hint: 'installed' }));
1410
- // Instruction lives in the header (not a separate console.log) and clearOnExit=true erases the whole
1411
- // picker after you choose — so first run stays ONE screen: banner → (pick flashes) → lobby.
1412
- return await promptSelect('First run pick your lead agent ↑↓ · enter · esc to skip', items, caps, 0, true);
1413
- }
1414
- /** True when the cwd already has files (an existing project being introduced to framein), vs a fresh,
1415
- * empty folder. Drives whether first-run init asks before touching files. `.frame` is framein's own. */
1416
- function projectHasFiles() {
1417
- try {
1418
- return readdirSync('.').some((f) => f !== '.frame');
1419
- }
1420
- catch {
1421
- return false;
1422
- }
1423
- }
1424
- /** First run inside an EXISTING project: show exactly what init will touch and confirm before writing.
1425
- * The key reassurance (the user's concern): framein never deletes or overwrites your files — it only
1426
- * adds/updates a marked `framein` block and preserves everything else. Returns true to proceed. TTY only. */
1427
- async function confirmInitInExistingProject(caps) {
1428
- const ui = painter(caps);
1429
- console.log(ui.tone('This folder already has files here’s exactly what setting up framein will do.', 'muted'));
1430
- console.log(ui.tone('It does NOT delete or overwrite anything: it only adds a marked `framein` block', 'brand'));
1431
- console.log(ui.tone('and leaves the rest of each file untouched (every change shows up in `git diff`).', 'brand'));
1432
- for (const n of ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']) {
1433
- const what = existsSync(n)
1434
- ? 'exists appends a managed block at the end; your content is preserved'
1435
- : 'will be created (new file)';
1436
- console.log(` • ${ui.tone(n.padEnd(10), 'muted')} ${ui.tone(what, 'muted')}`);
1437
- }
1438
- console.log(` • ${ui.tone('.frame/'.padEnd(10), 'muted')} ${ui.tone('local store (rebuildable cache add to .gitignore)', 'muted')}`);
1439
- const ans = await promptSelect('Set up framein in this project?', [
1440
- { value: 'yes', label: 'yes', hint: 'add the framein block(s) — nothing else is touched' },
1441
- { value: 'no', label: 'no', hint: 'skip — run `init` yourself later' },
1442
- ], caps, 0, true);
1443
- return ans === 'yes';
1444
- }
1445
- /** Lobby: after switching to an agent that's installed but has no wrappers yet, offer to install them.
1446
- * Raw-mode confirm the caller must have the readline interface closed (true in the /lead picker path). */
1447
- async function offerWrappers(agent, caps) {
1448
- if (!cliInstalled(agent))
1449
- return; // CLI not installed nothing to wrap
1450
- if (wrapperFiles(agent).every((f) => existsSync(f.path)))
1451
- return; // already installed
1452
- const ui = painter(caps);
1453
- const ans = await promptSelect(`Install ${agent} commands (${agent === 'codex' ? '$fr-*' : '/fr:*'})?`, [{ value: 'yes', label: 'yes', hint: 'install now' }, { value: 'no', label: 'no', hint: 'later' }], caps, 0, true);
1454
- if (ans === 'yes')
1455
- console.log(ui.tone(`installed ${writeWrappers(agent)} ${agent} wrapper(s)`, 'brand'));
1456
- }
1457
- /** Apply the path-independent lobby actions (everything EXCEPT exit / pickLead / launchLead, which need
1458
- * the caller's I/O context). Returns true if handled. Shared by the interactive (inline-palette) loop
1459
- * and the non-TTY readline path so the two never drift. */
1460
- function applyLobbyCommon(action, state, caps, interactive) {
1461
- const ui = painter(caps);
1462
- switch (action.kind) {
1463
- case 'noop': return true;
1464
- case 'help':
1465
- console.log(renderShellHelp());
1466
- return true;
1467
- case 'setLead':
1468
- state.lead = action.agent;
1469
- console.log(ui.tone(`lead ${ui.sym.next} ${state.lead}`, 'brand'));
1470
- if (interactive && cliInstalled(action.agent) && !wrapperFiles(action.agent).every((f) => existsSync(f.path)))
1471
- console.log(ui.tone(`tip: \`integrations install ${action.agent} --write\` adds its ${action.agent === 'codex' ? '$fr-*' : '/fr:*'} commands`, 'muted'));
1472
- return true;
1473
- case 'toggleTrust': {
1474
- const now = Date.now();
1475
- if (state.trustUntil && state.trustUntil > now) {
1476
- state.trustUntil = undefined;
1477
- console.log(ui.tone('trust OFF /go runs the lead with normal per-action approval prompts.', 'brand'));
1478
- }
1479
- else {
1480
- state.trustUntil = now + DEFAULT_TRUST_TTL_SEC * 1000;
1481
- console.log(ui.tone(`⚠ trust ON for ${Math.round(DEFAULT_TRUST_TTL_SEC / 60)}m — the next /go launches the lead WITHOUT per-action approval prompts.`, 'danger'));
1482
- console.log(ui.tone(' a worktree is NOT a sandbox: network, credentials, installs are not blocked. /trust again to disarm.', 'muted'));
1483
- }
1484
- return true;
1485
- }
1486
- case 'error':
1487
- console.error(ui.tone(action.message, 'danger'));
1488
- return true;
1489
- case 'engine':
1490
- if (action.args[0] === 'mcp' && action.args[1] === 'serve') {
1491
- console.error('`mcp serve` would consume the lobby stdin run it as `framein mcp serve` instead.');
1492
- return true;
1493
- }
1494
- try {
1495
- runCommand(action.args);
1496
- }
1497
- catch (e) {
1498
- if (e instanceof CliError) {
1499
- if (e.message)
1500
- console.error(e.message);
1501
- }
1502
- else
1503
- throw e;
1504
- }
1505
- process.exitCode = 0; // a command's failure code shouldn't doom the lobby session
1506
- return true;
1507
- default: return false; // exit / pickLead / launchLead — the caller's I/O context handles these
1508
- }
1509
- }
1510
- let inLobby = false; // true while the lobby owns stdin → cmdTask skips its own readline (no conflict)
1511
- function cmdShell() {
1512
- inLobby = true;
1513
- const interactive = Boolean(process.stdin.isTTY);
1514
- const caps = cliCaps();
1515
- const ui = painter(caps);
1516
- const arrow = caps.unicode ? '›' : '>';
1517
- process.exitCode = 0;
1518
- // Entry is wrapped in an async flow because the first-run wizard, the inline palette editor, and the
1519
- // /lead picker all await raw-mode input. Interactive sessions run the inline-palette lobbyLoop; non-TTY
1520
- // (piped) sessions use the plain readline open() loop. Both end by printing "bye".
1521
- void (async () => {
1522
- let lead = initialLead();
1523
- const firstRun = interactive && !existsSync(DB_PATH);
1524
- // Banner FIRST so the first thing you see is "you're in framein". The first-run picker then opens
1525
- // below it and ERASES itself on choice (clearOnExit), so what remains is a single lobby screen —
1526
- // not a stacked [pick]+[welcome] two-stage view.
1527
- if (interactive) {
1528
- const ver = readVersion().replace(/^framein /, 'v'); // e.g. v0.0.4
1529
- console.log(renderFrame('FRAMEIN', [`Framein by Frameout · ${ver}`, 'Intent in · Validation in · Drift out'], { ui, unicode: caps.unicode, columns: caps.columns }));
1530
- console.log('');
1531
- }
1532
- let setupNote = '';
1533
- if (firstRun) {
1534
- const chosen = await firstRunWizard(caps); // self-clearing picker
1535
- if (chosen) {
1536
- // Empty folder → set up silently. Existing project show what changes + confirm first (the
1537
- // managed block is additive and non-destructive, but we still ask before touching their files).
1538
- const proceed = projectHasFiles() ? await confirmInitInExistingProject(caps) : true;
1539
- if (proceed) {
1540
- try {
1541
- const n = cmdInit({ lead: chosen, quiet: true });
1542
- lead = chosen;
1543
- setupNote = ui.tone(`✓ set up · context synced${n ? ` · ${n} wrappers (/fr:* · $fr-*)` : ''}`, 'brand');
1544
- }
1545
- catch (e) {
1546
- if (e instanceof CliError && e.message)
1547
- console.error(e.message);
1548
- }
1549
- }
1550
- else {
1551
- lead = chosen; // chosen in-memory; nothing written
1552
- setupNote = ui.tone('Skipped setup — nothing was written. Run `init` when you’re ready.', 'muted');
1553
- }
1554
- }
1555
- }
1556
- const state = { lead };
1557
- if (interactive) {
1558
- console.log(ui.tone('Your home base for AI coding switch the lead agent, run a local check, or hand off with /go.', 'muted'));
1559
- console.log('');
1560
- console.log(renderKeyVals(shellStatusRows(state), ui));
1561
- console.log('');
1562
- if (setupNote)
1563
- console.log(setupNote);
1564
- console.log(ui.tone(existsSync(DB_PATH)
1565
- ? 'Type / to browse commands (filters as you type · ↑↓ to pick · ⏎ runs) · /go · /help · /exit'
1566
- : 'Not set up yet → type /init first. Then type / to browse commands · /help · /exit', 'muted'));
1567
- }
1568
- // Non-TTY (piped / automation) path: a plain readline loop. The interactive TTY path uses the inline
1569
- // `/` palette editor (readLobbyLine + lobbyLoop) below. Both route through applyLobbyCommon so the
1570
- // command behavior is identical; only the line-reading + exit/picker I/O differs.
1571
- const open = () => {
1572
- let sigintArmed = false;
1573
- const iface = createInterface({ input: process.stdin, output: process.stdout, completer: lobbyCompleter });
1574
- const next = () => { if (interactive) {
1575
- iface.setPrompt(`${ui.tone('fr', 'brand')}(${state.lead})${arrow} `);
1576
- iface.prompt();
1577
- } };
1578
- iface.on('line', (line) => {
1579
- sigintArmed = false;
1580
- const action = routeShellLine(line, state);
1581
- if (applyLobbyCommon(action, state, caps, interactive)) {
1582
- next();
1583
- return;
1584
- }
1585
- switch (action.kind) {
1586
- case 'exit':
1587
- iface.close();
1588
- return; // close handler prints bye
1589
- case 'pickLead':
1590
- console.log(`lead: ${state.lead}`);
1591
- break; // non-TTY fallback: never block on a picker
1592
- case 'launchLead':
1593
- launchLeadTui(state, action.agent, interactive, action.prompt, caps, () => iface.pause(), () => iface.resume());
1594
- break;
1595
- }
1596
- next();
1597
- });
1598
- iface.on('SIGINT', () => {
1599
- if (sigintArmed) {
1600
- iface.close();
1601
- return;
1602
- }
1603
- sigintArmed = true;
1604
- console.log(ui.tone("press Ctrl-C again (or type 'exit') to leave the lobby", 'muted'));
1605
- next();
1606
- });
1607
- iface.on('close', () => console.log(ui.tone('bye', 'muted')));
1608
- next();
1609
- };
1610
- // Interactive TTY path: read one line via the inline `/` palette editor, route it, repeat.
1611
- const lobbyLoop = async () => {
1612
- for (;;) {
1613
- const r = await readLobbyLine(state, caps);
1614
- if (r.kind === 'exit')
1615
- break;
1616
- const action = routeShellLine(r.line, state);
1617
- if (applyLobbyCommon(action, state, caps, true))
1618
- continue;
1619
- if (action.kind === 'exit')
1620
- break;
1621
- if (action.kind === 'pickLead') {
1622
- const picked = await promptLeadSelect(state.lead, caps);
1623
- if (picked && picked !== state.lead) {
1624
- state.lead = picked;
1625
- console.log(ui.tone(`lead ${ui.sym.next} ${state.lead}`, 'brand'));
1626
- await offerWrappers(picked, caps);
1627
- }
1628
- else
1629
- console.log(ui.tone(`lead: ${state.lead}`, 'muted'));
1630
- }
1631
- else if (action.kind === 'launchLead') {
1632
- launchLeadTui(state, action.agent, true, action.prompt, caps, () => { }, () => { });
1633
- }
1634
- }
1635
- console.log(ui.tone('bye', 'muted'));
1636
- };
1637
- if (interactive)
1638
- await lobbyLoop();
1639
- else
1640
- open();
1641
- })();
1642
- }
1643
- const CONTRACT_FIELDS = ['goal', 'preserve', 'acceptance', 'protected', 'nongoal'];
1644
- /** Loud, git-pointing notice whenever the Task Contract changes. The contract is tracked end-to-end,
1645
- * so every change — human- OR agent-driven — must be visible and reviewable (auto-applied, never
1646
- * silent; ADR-0012). git diff of the managed block is the audit + the undo. */
1647
- function surfaceContractChange(what) {
1648
- console.log(`⚠ Contract changed (${what}) — tracked end-to-end. Review/undo: git diff -- CLAUDE.md AGENTS.md GEMINI.md`);
1649
- }
1650
- /** Conversational `framein start` (no goal, terminal only): ask the contract fields one by one, then
1651
- * set it. Skipped inside the lobby (it owns stdin) and in non-TTY/automation (which need an explicit goal). */
1652
- async function runGuidedStart() {
1653
- const ui = painter(cliCaps());
1654
- const rl = createInterface({ input: process.stdin, output: process.stdout });
1655
- const ask = (q) => new Promise((res) => rl.question(`${ui.tone('?', 'brand')} ${q}\n `, res));
1656
- console.log(ui.tone('Guided start — define the contract (the definition of done). Empty goal aborts.', 'muted'));
1657
- const answers = {};
1658
- for (const step of GUIDED_CONTRACT_STEPS) {
1659
- const a = (await ask(step.question)).trim();
1660
- if (step.field === 'goal' && !a) {
1661
- console.log(ui.tone('No goal — aborted.', 'muted'));
1662
- rl.close();
1663
- return;
1664
- }
1665
- if (a)
1666
- answers[step.field] = a;
1667
- }
1668
- rl.close();
1669
- const c = buildGuidedContract(answers);
1670
- withStore((store) => {
1671
- withCliWriteLock(store, 'task', () => {
1672
- store.setTaskContract(c);
1673
- store.appendLedger('task-start', '', c.goal.slice(0, 200));
1674
- writeNativeFiles('.', store.getState());
1675
- console.log(`Task contract started: ${c.goal}`);
1676
- for (const i of contractIssues(c))
1677
- console.log(` ⚠ ${i}`);
1678
- surfaceContractChange('start');
1679
- });
1680
- });
1681
- }
1682
- function cmdTask(args) {
1683
- if ((args[0] ?? 'show') === 'start' && !args.slice(1).join(' ').trim() && Boolean(process.stdin.isTTY) && !inLobby) {
1684
- void runGuidedStart();
1685
- return; // conversational contract builder (terminal only)
1686
- }
1687
- withStore((store) => {
1688
- const sub = args[0] ?? 'show';
1689
- if (sub === 'start') {
1690
- const goal = args.slice(1).join(' ').trim();
1691
- if (!goal)
1692
- fail('Usage: framein task start <goal>');
1693
- withCliWriteLock(store, 'task', () => {
1694
- const c = emptyContract(goal);
1695
- store.setTaskContract(c);
1696
- store.appendLedger('task-start', '', goal.slice(0, 200));
1697
- writeNativeFiles('.', store.getState()); // project the contract as standing intent
1698
- console.log(`Task contract started: ${goal}`);
1699
- console.log(' Add criteria: framein task amend acceptance "<...>" (fields: ' + CONTRACT_FIELDS.join('|') + ')');
1700
- for (const i of contractIssues(c))
1701
- console.log(` ⚠ ${i}`);
1702
- surfaceContractChange('start');
1703
- });
1704
- }
1705
- else if (sub === 'amend') {
1706
- const field = args[1];
1707
- const value = args.slice(2).join(' ').trim();
1708
- if (!field || !CONTRACT_FIELDS.includes(field) || !value) {
1709
- fail(`Usage: framein task amend <${CONTRACT_FIELDS.join('|')}> <value>`);
1710
- }
1711
- withCliWriteLock(store, 'task', () => {
1712
- const cur = store.getTaskContract();
1713
- if (!cur)
1714
- fail('No active task contract. Run `framein task start <goal>` first.');
1715
- store.setTaskContract(amendContract(cur, field, value));
1716
- writeNativeFiles('.', store.getState());
1717
- console.log(`Amended ${field}; contract re-projected to the native files.`);
1718
- surfaceContractChange(`amend ${field}`);
1719
- });
1720
- }
1721
- else if (sub === 'show') {
1722
- const cur = store.getTaskContract();
1723
- if (wantsJson(args)) {
1724
- emitJson('task', { contract: cur ?? null, issues: cur ? contractIssues(cur) : [] });
1725
- return;
1726
- }
1727
- if (!cur) {
1728
- console.log('No active task contract. Run `framein task start <goal>`.');
1729
- return;
1730
- }
1731
- console.log(renderContractFull(cur, cliUi()));
1732
- for (const i of contractIssues(cur))
1733
- console.log(` ⚠ ${i}`);
1734
- }
1735
- else {
1736
- fail(`Unknown 'task' subcommand '${sub}'. Use: start | show | amend`);
1737
- }
1738
- });
1739
- }
1740
- // --- Disagreement Protocol (F-LOOP-5): bounded model-vs-model debate, lead keeps control ---
1741
- function flagValue(args, flag) {
1742
- const i = args.indexOf(flag);
1743
- if (i === -1)
1744
- return undefined;
1745
- const parts = [];
1746
- for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++)
1747
- parts.push(args[j]);
1748
- return parts.join(' ');
1749
- }
1750
- function cmdChallenge(args) {
1751
- withStore((store) => {
1752
- const reviewer = store.getRole('reviewer');
1753
- let d = store.getMemory('debate', 'current');
1754
- if (args.includes('--show')) {
1755
- if (!d) {
1756
- console.log('No open debate.');
1757
- return;
1758
- }
1759
- console.log(renderDebate(d, cliUi()));
1760
- return;
1761
- }
1762
- if (args.includes('--accept')) {
1763
- if (!d)
1764
- fail('No open debate to accept.');
1765
- d.entries.push({ kind: 'challenge', challenge: { verdict: 'accept', by: reviewer } });
1766
- store.setMemory('debate', 'current', d);
1767
- store.appendLedger('challenge', 'accept');
1768
- console.log(renderDebate(d, cliUi()));
1769
- return;
1770
- }
1771
- if (args.includes('--block')) {
1772
- if (!d)
1773
- fail('No open debate. Start one: framein challenge "<proposal>".');
1774
- const claim = flagValue(args, '--block') ?? '';
1775
- const requiredChange = flagValue(args, '--require');
1776
- d.entries.push({ kind: 'challenge', challenge: { verdict: 'challenge', claim, requiredChange, by: reviewer } });
1777
- store.setMemory('debate', 'current', d);
1778
- store.appendLedger('challenge', '', claim.slice(0, 80));
1779
- console.log(renderDebate(d, cliUi()));
1780
- return;
1781
- }
1782
- // --by <host>: which model is invoking (the agent wrapper passes its own host) → pick a reviewer ≠ it.
1783
- const byIdx = args.indexOf('--by');
1784
- const by = byIdx !== -1 ? args[byIdx + 1] : undefined;
1785
- // proposal text = positionals, skipping flags and --by's single value.
1786
- const positional = [];
1787
- for (let i = 0; i < args.length; i++) {
1788
- if (args[i] === '--by') {
1789
- i++;
1790
- continue;
1791
- }
1792
- if (args[i].startsWith('--'))
1793
- continue;
1794
- positional.push(args[i]);
1795
- }
1796
- const text = positional.join(' ').trim();
1797
- if (!text)
1798
- fail('Usage: framein challenge "<proposal>" [--run] | --block "<claim>" [--require "<change>"] | --accept | --show');
1799
- const proposer = by && isAgent(by) ? by : (store.getRole('lead') ?? store.getRole('implementer'));
1800
- d = newDebate(text, { text, by: proposer });
1801
- store.setMemory('debate', 'current', d);
1802
- console.log(renderDebate(d, cliUi()));
1803
- if (args.includes('--run')) {
1804
- const indep = independentReviewer(store, by);
1805
- if (!indep) {
1806
- console.log(`\n(no independent model available${by ? ` other than ${by}` : ''} — install another agent CLI, set a different \`reviewer\` role, or record manually with --block/--accept.)`);
1807
- return;
1808
- }
1809
- if (by && reviewer === by)
1810
- console.log(`(reviewer role is ${by} = the calling model — using ${indep} instead so the verdict is genuinely independent.)`);
1811
- runReviewerChallenge(store, d, indep, text);
1812
- return;
1813
- }
1814
- const hint = independentReviewer(store, by);
1815
- if (hint)
1816
- console.log(`\n(get an independent verdict from ${hint} with --run, or record one with --block/--accept)`);
1817
- });
1818
- }
1819
- /** A reviewer that is NOT the calling model (`by`) so an "independent challenge" is actually independent:
1820
- * the configured reviewer if it differs, else the first OTHER installed agent. undefined if none differ. */
1821
- function independentReviewer(store, by) {
1822
- const configured = store.getRole('reviewer');
1823
- if (configured && configured !== by)
1824
- return configured;
1825
- return AGENTS.find((a) => a !== by && agentAvailable(a));
1826
- }
1827
- /**
1828
- * Live structured ingest (M25): ask the reviewer for a JSON verdict on the proposal, extract it
1829
- * tolerantly, and record it as a Challenge in the debate. The prompt (fixed instruction + the
1830
- * proposal) goes via stdininjection-safe. Needs the real reviewer CLI.
1831
- */
1832
- function runReviewerChallenge(store, d, reviewer, proposal) {
1833
- const reviewPrompt = `You are the reviewer in a design debate. Decide whether to CHALLENGE or ACCEPT the proposal below. Reply with ONLY a JSON object, no prose: {"verdict":"challenge"|"accept","claim":"<one-line blocking claim, or empty>","requiredChange":"<the change you require, or empty>"}\n\nProposal:\n${proposal}`;
1834
- const inv = buildInvocation(reviewer, reviewPrompt);
1835
- console.log(`\nAsking reviewer ${reviewer} for a structured verdict…`);
1836
- const res = spawnSync(invocationCommand(inv), { input: inv.stdin, encoding: 'utf8', shell: true });
1837
- const parsed = extractJson(res.stdout ?? '');
1838
- const verdict = parsed?.verdict;
1839
- if (parsed && (verdict === 'challenge' || verdict === 'accept')) {
1840
- const claim = parsed.claim ? String(parsed.claim) : undefined;
1841
- const requiredChange = parsed.requiredChange ? String(parsed.requiredChange) : undefined;
1842
- d.entries.push({ kind: 'challenge', challenge: { verdict, claim, requiredChange, by: reviewer } });
1843
- store.setMemory('debate', 'current', d);
1844
- store.appendLedger('challenge', reviewer, String(verdict));
1845
- console.log(`(reviewer ${reviewer} responded)\n`);
1846
- console.log(renderDebate(d, cliUi()));
1847
- }
1848
- else {
1849
- console.log(`(reviewer ${reviewer} did not return a parseable verdict — record manually with --block/--accept; raw output:)`);
1850
- if (res.stdout)
1851
- process.stdout.write(res.stdout);
1852
- if (res.stderr)
1853
- process.stderr.write(res.stderr);
1854
- }
1855
- }
1856
- function cmdDecide(args) {
1857
- withStore((store) => {
1858
- const d = store.getMemory('debate', 'current');
1859
- if (!d)
1860
- fail('No open debate. Start one with `framein challenge "<proposal>"`.');
1861
- const verb = args[0];
1862
- const text = args.slice(1).join(' ').trim();
1863
- if (verb !== 'accept' && verb !== 'reject')
1864
- fail('Usage: framein decide accept|reject [text]');
1865
- d.entries.push({ kind: 'revision', revision: { text, accepted: verb === 'accept', by: store.getRole('lead') } });
1866
- store.setMemory('debate', 'current', d);
1867
- store.appendLedger('decide', verb, text.slice(0, 80));
1868
- console.log(renderDebate(d, cliUi()));
1869
- });
1870
- }
1871
- function cmdTrust(args) {
1872
- const agent = args.find((a) => !a.startsWith('--'));
1873
- if (!agent || !isAgent(agent))
1874
- fail(`Usage: framein trust <agent> [--ttl <dur>] (agent: ${AGENTS.join('|')})`);
1875
- let ttlSec;
1876
- const ttlIdx = args.indexOf('--ttl');
1877
- if (ttlIdx !== -1) {
1878
- const parsed = args[ttlIdx + 1] ? parseDuration(args[ttlIdx + 1]) : null;
1879
- if (parsed === null)
1880
- fail('Usage: framein trust <agent> --ttl <dur> (e.g. 30m, 1h, 90s)');
1881
- ttlSec = parsed;
1882
- }
1883
- const plan = trustPlan(agent, { ttlSec });
1884
- // preview only — framein does NOT enable bypass for you (F-TRUST: dangerous, explicit, opt-in).
1885
- console.log(`trust preview for ${agent} (time-box ~${Math.round(plan.ttlSec / 60)}m):`);
1886
- console.log(` would add: ${plan.flags.join(' ')}`);
1887
- for (const w of plan.warnings)
1888
- console.log(` ${w}`);
1889
- console.log(' framein does NOT auto-enable this — pass the flags yourself, scoped + time-boxed, when you launch.');
1890
- }
1891
- const HELP_ROWS = [
1892
- ['start <goal>', 'start a Task Contract (what "done" means)'],
1893
- ['task show|amend', 'show / amend the Task Contract'],
1894
- ['verify', 'run build/test + check validation vs the contract'],
1895
- ['ship', 'enforced Validation Gate (commit/deploy readiness)'],
1896
- ['risk', 'Blast Radius Guard: sensitive-file risk + gates'],
1897
- ['route explain [role]', 'repo-local routing: which agent + why · also: stats'],
1898
- ['recipe list|show|compile', 'vendor-neutral task protocols, compiled per CLI'],
1899
- ['debt | explain', 'debt delta of this change · ownership brief'],
1900
- ['rescue', 'detect a repair loop + propose options (no auto-action)'],
1901
- ['checkpoint [label]', 'mark the current commit green · also: rewind [--force]'],
1902
- ['pause | resume', 'save / restore a Task Capsule (handoff-free continuity)'],
1903
- ['challenge | decide', 'bounded reviewer debate (max 2 rounds, then escalate)'],
1904
- ['init', 'initialize store + project native files'],
1905
- ['rules show|set|reset', 'view / set the project rules the agent follows (editable defaults)'],
1906
- ['status', 'show roles, lock, decision count'],
1907
- ['role set <role> <agent>', 'assign a role (re-syncs files) · also: role list'],
1908
- ['adr add|supersede|show|list', 'record/replace/show/list decisions (append-only)'],
1909
- ['sync [--dry-run]', 're-project native files from the store'],
1910
- ['unlock [scope]', 'release a stale write lock (default: global)'],
1911
- ['export [path] | import [path]', 'write / rebuild the git-canonical snapshot (JSON)'],
1912
- ['mcp [patch|register|serve]', 'detected servers / patches / registration / thin server'],
1913
- ['skills', 'list framein + detected (reused) skills'],
1914
- ['ask <role> [prompt]', 'preview/record/run a headless delegation [--show|--run]'],
1915
- ['audit', 'report thrash/anomaly signals from the ledger'],
1916
- ['ledger add <kind> [t]', 'append a work-event (edit|test-fail|turn|commit…)'],
1917
- ['trust <agent> [--ttl d]', 'preview per-agent permission-bypass flags (no auto-enable)'],
1918
- ['lobby', 'optional interactive switchboard — also bare `framein` (zero-dep; native TUI on /go)'],
1919
- ['setup | doctor', 'detect agent CLIs + recommend/verify wrapper install'],
1920
- ['integrations <sub> [--write]', 'install/remove logic-less /fr:* wrappers (claude|codex|gemini)'],
1921
- ['--version | --help', 'version / this help'],
1922
- ];
1923
- function printHelp() {
1924
- console.log('framein (frame) keep AI coding aligned with intent, validate done, rescue when lost');
1925
- console.log('CLI: framein · aliases: frame, fr · slash namespace: /fr:* · automation: <verb> --json');
1926
- console.log('Commands:');
1927
- const w = Math.max(...HELP_ROWS.map(([c]) => c.length));
1928
- for (const [c, d] of HELP_ROWS)
1929
- console.log(` framein ${c.padEnd(w)} ${d}`);
1930
- }
1931
- function runCommand(argv) {
1932
- const [cmd, ...rest] = argv;
1933
- {
1934
- // per-command help: `framein <cmd> --help`
1935
- if (cmd && USAGE[cmd] && rest.includes('--help')) {
1936
- console.log(USAGE[cmd]);
1937
- return;
1938
- }
1939
- switch (cmd) {
1940
- case 'init':
1941
- cmdInit();
1942
- break;
1943
- case 'rules':
1944
- cmdRules(rest);
1945
- break;
1946
- case 'status':
1947
- cmdStatus(rest);
1948
- break;
1949
- case 'role':
1950
- cmdRole(rest);
1951
- break;
1952
- case 'adr':
1953
- cmdAdr(rest);
1954
- break;
1955
- case 'sync':
1956
- cmdSync(rest);
1957
- break;
1958
- case 'unlock':
1959
- cmdUnlock(rest);
1960
- break;
1961
- case 'export':
1962
- cmdExport(rest);
1963
- break;
1964
- case 'import':
1965
- cmdImport(rest);
1966
- break;
1967
- case 'mcp':
1968
- cmdMcp(rest);
1969
- break;
1970
- case 'skills':
1971
- cmdSkills();
1972
- break;
1973
- case 'ask':
1974
- cmdAsk(rest);
1975
- break;
1976
- case 'audit':
1977
- cmdAudit();
1978
- break;
1979
- case 'ledger':
1980
- cmdLedger(rest);
1981
- break;
1982
- case 'trust':
1983
- cmdTrust(rest);
1984
- break;
1985
- case 'task':
1986
- cmdTask(rest);
1987
- break;
1988
- case 'start':
1989
- cmdTask(['start', ...rest]);
1990
- break; // front-stage verb: start a Task Contract
1991
- case 'verify':
1992
- cmdVerify(rest);
1993
- break;
1994
- case 'ship':
1995
- cmdShip(rest);
1996
- break;
1997
- case 'risk':
1998
- cmdRisk(rest);
1999
- break;
2000
- case 'route':
2001
- cmdRoute(rest);
2002
- break;
2003
- case 'stats':
2004
- cmdStats(rest);
2005
- break;
2006
- case 'recipe':
2007
- cmdRecipe(rest);
2008
- break;
2009
- case 'debt':
2010
- cmdDebt(rest);
2011
- break;
2012
- case 'explain':
2013
- cmdExplain(rest);
2014
- break;
2015
- case 'rescue':
2016
- cmdRescue(rest);
2017
- break;
2018
- case 'checkpoint':
2019
- cmdCheckpoint(rest);
2020
- break;
2021
- case 'rewind':
2022
- cmdRewind(rest);
2023
- break;
2024
- case 'pause':
2025
- cmdPause();
2026
- break;
2027
- case 'resume':
2028
- cmdResume();
2029
- break;
2030
- case 'capsule':
2031
- cmdCapsule(rest);
2032
- break;
2033
- case 'challenge':
2034
- cmdChallenge(rest);
2035
- break;
2036
- case 'decide':
2037
- cmdDecide(rest);
2038
- break;
2039
- case 'integrations':
2040
- cmdIntegrations(rest);
2041
- break;
2042
- case 'doctor':
2043
- cmdDoctor();
2044
- break;
2045
- case 'setup':
2046
- cmdSetup();
2047
- break;
2048
- case '-v':
2049
- case '--version':
2050
- console.log(readVersion());
2051
- break;
2052
- case undefined:
2053
- // Bare `framein` in a terminal drops straight into the lobby; piped/CI invocation stays
2054
- // automation-safe and prints help (an interactive readline on a non-TTY would hang). The
2055
- // explicit `framein shell` verb keeps working for both. (stdio:'inherit' in bin.ts preserves
2056
- // the real TTY through the no-warnings re-exec, so isTTY is accurate here.)
2057
- if (process.stdin.isTTY) {
2058
- cmdShell();
2059
- break;
2060
- }
2061
- printHelp();
2062
- break;
2063
- case '-h':
2064
- case '--help':
2065
- printHelp();
2066
- break;
2067
- case 'lobby':
2068
- case 'shell':
2069
- cmdShell();
2070
- break; // `shell` kept as a hidden back-compat alias
2071
- default:
2072
- printHelp();
2073
- throw new CliError(`Unknown command '${cmd}'.`);
2074
- }
2075
- }
2076
- }
2077
- function main() {
2078
- try {
2079
- runCommand(process.argv.slice(2));
2080
- }
2081
- catch (e) {
2082
- if (e instanceof CliError) {
2083
- if (e.message)
2084
- console.error(e.message);
2085
- process.exit(1);
2086
- }
2087
- throw e;
2088
- }
2089
- }
2090
- main();
1
+ #!/usr/bin/env node
2
+ // framein — the framein orchestrator CLI (prototype subset).
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { createInterface, emitKeypressEvents, moveCursor, clearScreenDown } from 'node:readline';
6
+ import { basename, dirname, join } from 'node:path';
7
+ import { Store } from './store.js';
8
+ import { writeNativeFiles, planNativeFiles } from './fileWriter.js';
9
+ import { ROLES, AGENTS } from './types.js';
10
+ import { isAgent, isRole, selectAgent, DEFAULT_ROLE_PRIORITY } from './roles.js';
11
+ import { HANDOFF_START_PROMPT, buildInvocation, resolveAgent, renderInvocation, invocationCommand, interactiveCommand } from './delegate.js';
12
+ import { detectQuotaSignal } from './quota.js';
13
+ import { trustPlan, parseDuration, DEFAULT_TRUST_TTL_SEC } from './trust.js';
14
+ import { emptyContract, amendContract, contractIssues, renderContractFull, buildGuidedContract, GUIDED_CONTRACT_STEPS } from './task.js';
15
+ import { gate, renderGate, renderShip, parseTestSummary } from './evidence.js';
16
+ import { buildRescue, renderRescue } from './rescue.js';
17
+ import { buildCapsule, renderCapsule } from './capsule.js';
18
+ import { debateStatus, newDebate, renderDebate } from './disagree.js';
19
+ import { buildLeadResponsePrompt, buildReviewerPrompt, challengeFromVerdict, normalizeLeadModelResponse, normalizeReviewerVerdict, renderDecisionBrief, responseFromLeadModel, } from './challenge.js';
20
+ import { extractJson } from './ingest.js';
21
+ import { assessBlastRadius, renderBlast, riskTransition } from './blast.js';
22
+ import { computeRepoStats, explainRoute, renderRouteExplain, renderStats } from './stats.js';
23
+ import { listRecipes, getRecipe, renderRecipe, compileRecipe } from './recipe.js';
24
+ import { parseDiffDebt, renderDebt } from './debt.js';
25
+ import { ownershipBrief } from './brief.js';
26
+ import { detectMcpFromDisk, detectSkillsFromDisk, findConflicts, frameinMcpRegistration, FRAMEIN_SKILLS } from './detect.js';
27
+ import { applyJsonMcp, applyCodexMcp, resolveFrameinEntry } from './mcpRegister.js';
28
+ import { detectThrash } from './anomaly.js';
29
+ import { serve } from './mcpServer.js';
30
+ import { wrapperFiles, WRAP_VERBS, PROVENANCE } from './wrappers.js';
31
+ import { routeShellLine, renderShellHelp, handoffCardRows, lobbyCompleter, LOBBY_PALETTE } from './shell.js';
32
+ import { initSelect, reduceSelectKey, renderSelectLines } from './select.js';
33
+ import { initPalette, reducePaletteKey, renderPaletteSuggestions, paletteSuggestions } from './palette.js';
34
+ import { resolveCapabilities } from './ui/capabilities.js';
35
+ import { painter } from './ui/theme.js';
36
+ import { renderFrame, renderKeyVals } from './ui/banner.js';
37
+ const SNAPSHOT_PATH = 'framein.store.json';
38
+ const FRAME_DIR = '.frame';
39
+ const DB_PATH = join(FRAME_DIR, 'store.db');
40
+ /** A user-facing error: printed to stderr, exits 1, never a stack trace. */
41
+ class CliError extends Error {
42
+ }
43
+ function fail(message) { throw new CliError(message); }
44
+ function rel(p) { return p.replace(/^[.][/\\]/, ''); }
45
+ function sleepMs(ms) {
46
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
47
+ }
48
+ function openStore() {
49
+ if (!existsSync(DB_PATH))
50
+ fail('No .frame/store.db found. Run `framein init` first.');
51
+ return Store.open(DB_PATH);
52
+ }
53
+ /** Open the store, run fn, and always close it (even on error). */
54
+ function withStore(fn) {
55
+ const store = openStore();
56
+ try {
57
+ return fn(store);
58
+ }
59
+ finally {
60
+ store.close();
61
+ }
62
+ }
63
+ function withCliWriteLock(store, scope, fn) {
64
+ const holder = `framein-cli:${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
65
+ const deadline = Date.now() + 5000;
66
+ while (!store.acquireLock(holder, { scope, ttlMs: 30_000 })) {
67
+ if (Date.now() >= deadline) {
68
+ throw new CliError(`Write lock '${scope}' is held by '${store.getLockHolder(scope) ?? 'unknown'}'. Retry or run \`framein unlock ${scope}\` if stale.`);
69
+ }
70
+ sleepMs(50);
71
+ }
72
+ try {
73
+ return fn();
74
+ }
75
+ finally {
76
+ store.releaseLock(holder, { scope });
77
+ }
78
+ }
79
+ function ensureFrameDirIgnored(dir = '.') {
80
+ const path = join(dir, '.gitignore');
81
+ const entry = '.frame/';
82
+ const existing = existsSync(path) ? readFileSync(path, 'utf8') : '';
83
+ const ignored = existing
84
+ .split(/\r?\n/)
85
+ .map((line) => line.trim())
86
+ .some((line) => line === '.frame/' || line === '.frame');
87
+ if (ignored)
88
+ return false;
89
+ const prefix = existing && !existing.endsWith('\n') ? '\n' : '';
90
+ writeFileSync(path, `${existing}${prefix}${entry}\n`, 'utf8');
91
+ return true;
92
+ }
93
+ function parseId(raw, usage) {
94
+ const n = Number(raw);
95
+ if (!raw || !Number.isInteger(n) || n <= 0)
96
+ fail(usage);
97
+ return n;
98
+ }
99
+ // Seeded project rules. Deliberately OUTCOME-oriented and judgment-based, not hard mandates: framein
100
+ // ENFORCES the Validation Gate (tests vs the contract), and only SUGGESTS method. So TDD/deep-modules are
101
+ // encouraged-with-judgment, not imposed different teams differ, and the gate doesn't need a method
102
+ // (ADR-0012 / the "editable opinionated defaults" decision). Editable per project via `framein rules set`.
103
+ const DEFAULT_RULES = [
104
+ '- Don\'t claim "done" without validation build/test checks backing the contract must pass. (Test-first/TDD encouraged for non-trivial logic; use judgment on prototypes & throwaways.)',
105
+ '- Prefer deep modules keep interfaces small relative to the implementation behind them.',
106
+ '- Record significant decisions as ADRs.',
107
+ ].join('\n');
108
+ function cmdInit(opts = {}) {
109
+ mkdirSync(FRAME_DIR, { recursive: true });
110
+ const ignoredFrameDir = ensureFrameDirIgnored('.');
111
+ const store = Store.open(DB_PATH);
112
+ try {
113
+ if (store.getAllConfig()['rules'] === undefined) {
114
+ store.setConfig('rules', DEFAULT_RULES);
115
+ }
116
+ if (Object.keys(store.getRoles()).length === 0) {
117
+ store.setRole('implementer', 'claude');
118
+ store.setRole('reviewer', 'codex');
119
+ store.setRole('explainer', 'gemini');
120
+ }
121
+ if (opts.lead)
122
+ store.setRole('implementer', opts.lead); // first-run wizard's chosen lead, in one step
123
+ writeNativeFiles('.', store.getState());
124
+ // Auto-install host-native wrappers for any agent CLI already on PATH, so /fr:* (Claude/Gemini)
125
+ // and $fr-* (Codex) work immediately. Newly-installed agents get offered when you switch to them.
126
+ const hosts = WRAP_HOSTS.filter(cliInstalled);
127
+ let n = 0;
128
+ for (const h of hosts)
129
+ n += writeWrappers(h);
130
+ if (!opts.quiet) {
131
+ // Top-level `framein init`. The lobby first-run path passes quiet (it prints its own folded line).
132
+ console.log(`Initialized framein in ${FRAME_DIR}/`);
133
+ console.log('Projected synchronized context: CLAUDE.md, AGENTS.md, GEMINI.md');
134
+ if (ignoredFrameDir)
135
+ console.log('Ignored local cache: .frame/ (use `framein export` for the git-canonical snapshot).');
136
+ if (hosts.length)
137
+ console.log(`Installed ${n} host wrapper(s) for ${hosts.join(', ')} — /fr:verify (Claude/Gemini) · $fr-verify (Codex).`);
138
+ }
139
+ return n;
140
+ }
141
+ finally {
142
+ store.close();
143
+ }
144
+ }
145
+ // On Windows the agent runs wrapper commands through PowerShell, where the bare `framein` resolves to
146
+ // `framein.ps1` and the default execution policy BLOCKS it (UnauthorizedAccess) even if YOU launched
147
+ // the agent from Git Bash, because the agent picks its own shell. `framein.cmd` is policy-proof across
148
+ // PowerShell/cmd/Git Bash, so we target it in generated wrappers on Windows. (Per-machine artifacts
149
+ // regenerate with `integrations install` on each OS; the bin name isn't portable across platforms.)
150
+ const WRAPPER_BIN = process.platform === 'win32' ? 'framein.cmd' : 'framein';
151
+ /** Write a host's wrapper files (idempotent). Returns the count. Shared by `init` auto-install,
152
+ * `integrations install`, and the lobby's offer-on-lead-switch. */
153
+ function writeWrappers(host) {
154
+ let n = 0;
155
+ for (const f of wrapperFiles(host, WRAPPER_BIN)) {
156
+ mkdirSync(dirname(f.path), { recursive: true });
157
+ writeFileSync(f.path, f.content);
158
+ n++;
159
+ }
160
+ return n;
161
+ }
162
+ /** View / set / reset the project rules (the agent-guidance block). Rules are SUGGESTIONS the agent
163
+ * reads — the Validation Gate is what's enforced so teams can shape them freely. `set` re-projects so
164
+ * the change reaches all three native files (editing the projected block directly is overwritten). */
165
+ function cmdRules(args) {
166
+ const sub = args[0] ?? 'show';
167
+ withStore((store) => {
168
+ if (sub === 'set' || sub === 'reset') {
169
+ let text;
170
+ if (sub === 'reset') {
171
+ text = DEFAULT_RULES;
172
+ }
173
+ else {
174
+ const inline = args.slice(1).filter((a) => a !== '--json').join(' ').trim();
175
+ const piped = !inline && !process.stdin.isTTY ? (() => { try {
176
+ return readFileSync(0, 'utf8').trim();
177
+ }
178
+ catch {
179
+ return '';
180
+ } })() : '';
181
+ text = (inline || piped).replace(/\\n/g, '\n'); // allow \n escapes in a one-line arg
182
+ if (!text)
183
+ fail('Usage: framein rules set "<text>" (use \\n for line breaks, or pipe the text on stdin)');
184
+ }
185
+ store.setConfig('rules', text);
186
+ writeNativeFiles('.', store.getState());
187
+ console.log(`Project rules ${sub === 'reset' ? 'reset to defaults' : 'updated'}; re-projected to CLAUDE.md, AGENTS.md, GEMINI.md.`);
188
+ return;
189
+ }
190
+ if (sub !== 'show')
191
+ fail("Unknown 'rules' subcommand. Use: show | set <text> | reset");
192
+ const raw = store.getConfig('rules');
193
+ const cur = (typeof raw === 'string' ? raw : '').trim();
194
+ if (wantsJson(args)) {
195
+ emitJson('rules', { rules: cur });
196
+ return;
197
+ }
198
+ console.log(cur || '_No project rules defined._ (set with `framein rules set "<…>"`)');
199
+ });
200
+ }
201
+ // Terminal capabilities / painter for the current process (style guide §12/§13.2). Recomputed per
202
+ // call (cheap); color is auto-off for pipes/CI/--json, so automation output stays plain.
203
+ function cliCaps() {
204
+ const out = process.stdout;
205
+ return resolveCapabilities({
206
+ isTTY: Boolean(process.stdout.isTTY),
207
+ columns: out.columns,
208
+ colorDepth: process.stdout.isTTY && typeof out.getColorDepth === 'function' ? out.getColorDepth() : 1,
209
+ platform: process.platform,
210
+ env: process.env,
211
+ flags: process.argv,
212
+ });
213
+ }
214
+ function cliUi() { return painter(cliCaps()); }
215
+ // `--json`: stable machine output for wrappers/automation (ADR-0010). schemaVersion + command.
216
+ function wantsJson(args) { return args.includes('--json'); }
217
+ function emitJson(command, payload) {
218
+ console.log(JSON.stringify({ schemaVersion: 1, command, ...payload }));
219
+ }
220
+ function cmdStatus(args = []) {
221
+ withStore((store) => {
222
+ const roles = store.getRoles();
223
+ if (wantsJson(args)) {
224
+ emitJson('status', {
225
+ store: DB_PATH,
226
+ lock: store.getLockHolder() ?? null,
227
+ roles,
228
+ decisions: store.listAdrs().length,
229
+ goal: store.getTaskContract()?.goal ?? null,
230
+ });
231
+ return;
232
+ }
233
+ // Single-string console.log (not multi-arg) so Node never inspect-colors values out of band
234
+ // color is the painter's job, and this keeps NO_COLOR honored even when FORCE_COLOR is set.
235
+ const ui = cliUi();
236
+ console.log(ui.bold('framein status'));
237
+ console.log(` store : ${DB_PATH}`);
238
+ console.log(` lock : ${store.getLockHolder() ?? '(free)'}`);
239
+ console.log(` roles : ${Object.keys(roles).length ? Object.entries(roles).map(([r, a]) => `${r}→${a}`).join(', ') : '(none)'}`);
240
+ console.log(` decisions : ${store.listAdrs().length}`);
241
+ });
242
+ }
243
+ function cmdRole(args) {
244
+ withStore((store) => {
245
+ const sub = args[0];
246
+ if (sub === 'set') {
247
+ const role = args[1];
248
+ const agent = args[2];
249
+ if (!role || !agent)
250
+ fail('Usage: framein role set <role> <agent>');
251
+ if (!isRole(role))
252
+ fail(`Unknown role '${role}'. Valid: ${ROLES.join(', ')}`);
253
+ if (!isAgent(agent))
254
+ fail(`Unknown agent '${agent}'. Valid: ${AGENTS.join(', ')}`);
255
+ store.setRole(role, agent);
256
+ writeNativeFiles('.', store.getState());
257
+ console.log(`Set ${role} -> ${agent} (native files re-synced)`);
258
+ }
259
+ else if (sub === undefined || sub === 'list') {
260
+ const roles = store.getRoles();
261
+ if (Object.keys(roles).length === 0)
262
+ console.log(' (no roles assigned)');
263
+ for (const [r, a] of Object.entries(roles))
264
+ console.log(` ${r} -> ${a}`);
265
+ }
266
+ else {
267
+ fail(`Unknown 'role' subcommand '${sub}'. Use: set | list`);
268
+ }
269
+ });
270
+ }
271
+ function cmdAdr(args) {
272
+ withStore((store) => {
273
+ const sub = args[0];
274
+ if (sub === 'add') {
275
+ const title = args.slice(1).join(' ').trim();
276
+ if (!title)
277
+ fail('Usage: framein adr add <title>');
278
+ const adr = store.appendAdr({ title, decision: title });
279
+ writeNativeFiles('.', store.getState());
280
+ console.log(`Recorded ADR-${adr.id}: ${adr.title} (all three files updated)`);
281
+ }
282
+ else if (sub === 'supersede') {
283
+ const oldId = parseId(args[1], 'Usage: framein adr supersede <id> <title>');
284
+ const title = args.slice(2).join(' ').trim();
285
+ if (!title)
286
+ fail('Usage: framein adr supersede <id> <title>');
287
+ if (!store.getAdr(oldId))
288
+ fail(`ADR-${oldId} not found`);
289
+ if (store.isSuperseded(oldId))
290
+ fail(`ADR-${oldId} is already superseded`);
291
+ const adr = store.supersedeAdr(oldId, { title, decision: title });
292
+ writeNativeFiles('.', store.getState());
293
+ console.log(`Recorded ADR-${adr.id} superseding ADR-${oldId} (all three files updated)`);
294
+ }
295
+ else if (sub === 'show') {
296
+ const id = parseId(args[1], 'Usage: framein adr show <id>');
297
+ const adr = store.getAdr(id);
298
+ if (!adr)
299
+ fail(`ADR-${id} not found`);
300
+ const status = store.isSuperseded(adr.id) ? 'superseded' : adr.status;
301
+ console.log(`ADR-${adr.id}: ${adr.title}`);
302
+ console.log(` status : ${status}`);
303
+ console.log(` created : ${adr.createdAt}`);
304
+ if (adr.supersedes != null)
305
+ console.log(` supersedes : ADR-${adr.supersedes}`);
306
+ if (adr.authorAgent)
307
+ console.log(` author : ${adr.authorAgent}`);
308
+ if (adr.context)
309
+ console.log(` context : ${adr.context}`);
310
+ console.log(` decision : ${adr.decision}`);
311
+ if (adr.consequences)
312
+ console.log(` consequences: ${adr.consequences}`);
313
+ }
314
+ else if (sub === undefined || sub === 'list') {
315
+ const adrs = store.listAdrs();
316
+ if (adrs.length === 0)
317
+ console.log(' (no decisions recorded)');
318
+ for (const a of adrs) {
319
+ const status = store.isSuperseded(a.id) ? 'superseded' : a.status;
320
+ console.log(` ADR-${a.id} ${a.title} (${status})`);
321
+ }
322
+ }
323
+ else {
324
+ fail(`Unknown 'adr' subcommand '${sub}'. Use: add | supersede | show | list`);
325
+ }
326
+ });
327
+ }
328
+ function cmdSync(args) {
329
+ withStore((store) => {
330
+ if (args.includes('--dry-run')) {
331
+ for (const p of planNativeFiles('.', store.getState())) {
332
+ console.log(` ${p.changed ? 'CHANGE ' : 'unchanged'} ${rel(p.path)}${p.existed ? '' : ' (new)'}`);
333
+ }
334
+ console.log('(dry-run: no files written)');
335
+ }
336
+ else {
337
+ const written = writeNativeFiles('.', store.getState());
338
+ if (written.length === 0)
339
+ console.log('Already in sync (no changes).');
340
+ else
341
+ console.log(`Synced from source of truth: ${written.map(rel).join(', ')}`);
342
+ }
343
+ });
344
+ }
345
+ function cmdUnlock(args) {
346
+ withStore((store) => {
347
+ const scope = args[0] ?? 'global';
348
+ const prev = store.getLockHolder(scope);
349
+ store.forceUnlock(scope);
350
+ console.log(prev ? `Released write lock on '${scope}' (was held by '${prev}').` : `No active lock on '${scope}'.`);
351
+ });
352
+ }
353
+ function cmdExport(args) {
354
+ const out = args.find((a) => !a.startsWith('-')) ?? SNAPSHOT_PATH;
355
+ withStore((store) => {
356
+ writeFileSync(out, JSON.stringify(store.exportSnapshot(), null, 2) + '\n', 'utf8');
357
+ console.log(`Exported canonical snapshot to ${out}`);
358
+ });
359
+ }
360
+ function cmdImport(args) {
361
+ const src = args.find((a) => !a.startsWith('-')) ?? SNAPSHOT_PATH;
362
+ if (!existsSync(src))
363
+ fail(`Snapshot not found: ${src}`);
364
+ let snap;
365
+ try {
366
+ snap = JSON.parse(readFileSync(src, 'utf8'));
367
+ }
368
+ catch {
369
+ return fail(`Not valid JSON: ${src}`);
370
+ }
371
+ mkdirSync(FRAME_DIR, { recursive: true });
372
+ const store = Store.open(DB_PATH);
373
+ try {
374
+ store.importSnapshot(snap);
375
+ writeNativeFiles('.', store.getState());
376
+ console.log(`Imported ${src} -> ${DB_PATH} (native files re-synced)`);
377
+ }
378
+ catch (e) {
379
+ fail(`Import failed: ${e.message}`);
380
+ }
381
+ finally {
382
+ store.close();
383
+ }
384
+ }
385
+ const VALUE_FLAGS = new Set(['--ttl']); // flags that consume the following token as their value
386
+ function cmdAsk(args) {
387
+ const flags = [];
388
+ const positional = [];
389
+ for (let i = 0; i < args.length; i++) {
390
+ const a = args[i];
391
+ if (VALUE_FLAGS.has(a)) {
392
+ flags.push(a);
393
+ i++;
394
+ continue;
395
+ } // skip the value so it can't leak into the prompt
396
+ if (a.startsWith('--')) {
397
+ flags.push(a);
398
+ continue;
399
+ }
400
+ positional.push(a);
401
+ }
402
+ const role = positional[0];
403
+ if (!role || !isRole(role))
404
+ fail(`Usage: framein ask <role> [prompt] [--show|--run] (role: ${ROLES.join('|')})`);
405
+ let prompt = positional.slice(1).join(' ').trim();
406
+ const interactive = flags.includes('--interactive');
407
+ if (!prompt && !interactive && !process.stdin.isTTY) {
408
+ try {
409
+ prompt = readFileSync(0, 'utf8').trim();
410
+ }
411
+ catch { /* no stdin */ }
412
+ }
413
+ if (!prompt && !interactive)
414
+ fail('No prompt. Pipe it on stdin or pass it after the role.');
415
+ const useTrust = flags.includes('--trust');
416
+ withStore((store) => {
417
+ const agent = resolveAgent(store.getRoles(), role);
418
+ if (interactive) { // zero-dep human-in-the-loop attach: hand the agent's own TUI the terminal
419
+ const cmd = interactiveCommand(agent);
420
+ if (flags.includes('--show')) {
421
+ console.log(`would attach to ${agent} interactively: ${cmd} (stdio:inherit)`);
422
+ return;
423
+ }
424
+ console.log(`Attaching to ${agent} interactively — framein context is synced; you drive it. Exit the agent to return.`);
425
+ store.appendLedger('attach', `${role}:${agent}`);
426
+ const res = spawnSync(cmd, { stdio: 'inherit', shell: true });
427
+ if (res.error)
428
+ fail(`Failed to launch ${agent}: ${res.error.message}`);
429
+ return;
430
+ }
431
+ let trustFlags;
432
+ if (useTrust) { // F-TRUST: opt-in, time-boxed permission bypass wired into the spawn
433
+ const ttlRaw = (() => { const i = args.indexOf('--ttl'); return i !== -1 ? args[i + 1] : undefined; })();
434
+ const plan = trustPlan(agent, { ttlSec: ttlRaw ? parseDuration(ttlRaw) ?? undefined : undefined });
435
+ trustFlags = plan.flags;
436
+ console.log(`⚠ TRUST ON for ${agent} (time-box ~${Math.round(plan.ttlSec / 60)}m): adds ${plan.flags.join(' ')}`);
437
+ for (const w of plan.warnings)
438
+ console.log(` ⚠ ${w}`);
439
+ }
440
+ const inv = buildInvocation(agent, prompt, { trustFlags });
441
+ if (flags.includes('--show')) { // safe preview: resolve + build, no spawn, no ledger write
442
+ console.log(`would run (${role} → ${agent}): ${renderInvocation(inv)}`);
443
+ return;
444
+ }
445
+ store.appendLedger('ask', role, prompt.slice(0, 200));
446
+ store.appendLedger('turn', role);
447
+ if (useTrust)
448
+ store.appendLedger('trust', agent, trustFlags?.join(' ') ?? '');
449
+ if (flags.includes('--run')) {
450
+ runDelegated(store, role, agent, inv);
451
+ return;
452
+ }
453
+ console.log(`Queued ask for role '${role}' ${agent} (recorded in the ledger).`);
454
+ console.log(` Preview: framein ask ${role} <prompt> --show · live run: --run (spawns the ${agent} CLI headless).`);
455
+ const signals = detectThrash(store.listLedger());
456
+ if (signals.length) {
457
+ console.log(' ⚠ audit signals:');
458
+ for (const s of signals)
459
+ console.log(` - ${s.message}`);
460
+ }
461
+ });
462
+ }
463
+ /**
464
+ * Live headless delegation (B-2): spawn the agent's non-interactive CLI, stream its output, record
465
+ * the outcome + quota signal (failover hint) + a result snippet (ingest). Verified live against
466
+ * real claude (`claude -p`) and codex (`codex exec`); the automated suite covers resolve/build
467
+ * (`--show`) and the store recording, not the spawn itself (it needs the real CLI + tokens).
468
+ */
469
+ function runDelegated(store, role, agent, inv) {
470
+ console.log(`Delegating ${role} ${agent}: ${renderInvocation(inv)}`);
471
+ // shell:true resolves npm .cmd shims (codex/gemini) on Windows; the command is FIXED flags only
472
+ // (no user input — injection-safe), and the prompt is fed via stdin (inv.stdin).
473
+ const res = spawnSync(invocationCommand(inv), { input: inv.stdin, encoding: 'utf8', shell: true });
474
+ if (res.error) {
475
+ store.appendLedger('delegate-fail', `${role}:${agent}`, res.error.message);
476
+ fail(`Failed to launch ${agent}: ${res.error.message}`);
477
+ }
478
+ const stdout = res.stdout ?? '';
479
+ const stderr = res.stderr ?? '';
480
+ if (res.status !== 0 && !stdout.trim() && /not recognized|not found|no such file/i.test(stderr)) {
481
+ store.appendLedger('delegate-fail', `${role}:${agent}`, 'cli-not-found');
482
+ fail(`'${inv.command}' not found — install the ${agent} CLI, or use --show to preview the command.`);
483
+ }
484
+ if (stdout)
485
+ process.stdout.write(stdout);
486
+ if (stderr)
487
+ process.stderr.write(stderr);
488
+ const ok = res.status === 0;
489
+ const sig = detectQuotaSignal(agent, `${stdout}\n${stderr}`);
490
+ if (sig.exhausted) {
491
+ store.appendLedger('quota', agent, sig.kind ?? '');
492
+ const alt = selectAgent(DEFAULT_ROLE_PRIORITY[role], { role, authMode: {}, unavailable: { [agent]: true } });
493
+ const retry = sig.retryAfterSec ? ` (retry ~${sig.retryAfterSec}s)` : '';
494
+ console.error(`⚠ ${agent} looks ${sig.kind}${retry} — consider failover${alt ? ` to ${alt}` : ' (no alternative available)'}.`);
495
+ }
496
+ // Ingest (F-LOOP-4 tie-in): record the result so the capsule + other agents (via read_memory
497
+ // scope 'delegation') see what the delegated agent produced. Only a short snippet is stored
498
+ // the full output stays on the terminal (commit-forbidden-data caution, PRD §12).
499
+ const snippet = stdout.trim().split('\n').filter(Boolean).slice(0, 3).join(' ').slice(0, 200);
500
+ store.setMemory('delegation', 'last', { role, agent, ok, snippet, ts: new Date().toISOString() });
501
+ store.appendLedger(ok ? 'delegated' : 'delegate-fail', `${role}:${agent}`);
502
+ if (!ok)
503
+ process.exitCode = res.status ?? 1;
504
+ }
505
+ function cmdAudit() {
506
+ withStore((store) => {
507
+ const signals = detectThrash(store.listLedger());
508
+ if (signals.length === 0) {
509
+ console.log('No anomaly signals. (audit is blocker-only by default — ADR-0005)');
510
+ return;
511
+ }
512
+ console.log('Audit signals (consider pulling in the reviewer):');
513
+ for (const s of signals)
514
+ console.log(` - [${s.kind}] ${s.message}`);
515
+ });
516
+ }
517
+ function cmdLedger(args) {
518
+ withStore((store) => {
519
+ const sub = args[0];
520
+ if (sub === 'add') {
521
+ const kind = args[1];
522
+ if (!kind)
523
+ fail('Usage: framein ledger add <kind> [target] [detail]');
524
+ store.appendLedger(kind, args[2] ?? '', args.slice(3).join(' '));
525
+ console.log(`Ledger += ${kind}${args[2] ? ' ' + args[2] : ''}`);
526
+ }
527
+ else if (sub === undefined || sub === 'list') {
528
+ const entries = store.listLedger(50);
529
+ if (entries.length === 0)
530
+ console.log(' (ledger empty)');
531
+ for (const e of entries)
532
+ console.log(` ${e.kind}${e.target ? ' ' + e.target : ''}`);
533
+ }
534
+ else {
535
+ fail(`Unknown 'ledger' subcommand '${sub}'. Use: add | list`);
536
+ }
537
+ });
538
+ }
539
+ async function serveMcp() {
540
+ const store = openStore();
541
+ try {
542
+ await serve(store);
543
+ }
544
+ catch (e) {
545
+ console.error(String(e));
546
+ process.exitCode = 1;
547
+ }
548
+ finally {
549
+ store.close();
550
+ }
551
+ }
552
+ function cmdMcp(args) {
553
+ if (args[0] === 'serve') {
554
+ void serveMcp();
555
+ return;
556
+ }
557
+ if (args[0] === 'register') {
558
+ const rest = args.slice(1);
559
+ const write = rest.includes('--write');
560
+ const target = rest.find((a) => !a.startsWith('--')) ?? '.mcp.json';
561
+ const isToml = target.endsWith('.toml');
562
+ const existing = existsSync(target) ? readFileSync(target, 'utf8') : null;
563
+ // Use the canonical `framein` bin if installed; else `node <this cli.js>` so the agent can
564
+ // actually spawn the server (dev / not-globally-installed). The agent runs this verbatim.
565
+ const probe = spawnSync('framein', ['--version'], { encoding: 'utf8', shell: true });
566
+ const frameOnPath = probe.status === 0 && /framein/i.test((probe.stdout ?? '') + (probe.stderr ?? ''));
567
+ const entry = resolveFrameinEntry(frameOnPath, process.argv[1] ?? 'dist/cli.js');
568
+ const merged = isToml ? applyCodexMcp(existing, 'framein', entry) : applyJsonMcp(existing, 'framein', entry);
569
+ console.log(`# server command: ${entry.command} ${entry.args.join(' ')} (${frameOnPath ? 'framein on PATH' : 'node fallback — framein not globally installed'})`);
570
+ if (write) {
571
+ writeFileSync(target, merged);
572
+ console.log(`Registered framein in ${target} (${isToml ? 'TOML' : 'JSON'} merge existing content preserved).`);
573
+ console.log('Verify the live connection: `claude mcp list` and look for framein (codex/gemini have equivalents).');
574
+ }
575
+ else {
576
+ console.log(`# preview — would merge framein into ${target} (${isToml ? 'TOML' : 'JSON'}). Re-run with --write to apply:`);
577
+ console.log(merged);
578
+ }
579
+ return;
580
+ }
581
+ if (args[0] === 'patch') {
582
+ const reg = frameinMcpRegistration();
583
+ console.log("# Register framein's MCP server with each CLI (apply after review framein won't write these):");
584
+ console.log('\n## Claudemerge into .mcp.json:\n' + reg.claude);
585
+ console.log('\n## Codexadd to ~/.codex/config.toml:\n' + reg.codex);
586
+ console.log('\n## Gemini merge into settings.json:\n' + reg.gemini);
587
+ console.log('\n# NOTE: `framein mcp serve` speaks the MCP stdio transport (newline-delimited JSON-RPC,');
588
+ console.log('# which IS MCP stdio not LSP Content-Length framing). Applying these patches and');
589
+ console.log('# verifying the live connection is the orchestration layer (B; ADR-0006/0007).');
590
+ return;
591
+ }
592
+ const servers = detectMcpFromDisk();
593
+ if (servers.length === 0) {
594
+ console.log('No existing MCP servers detected (.mcp.json, ~/.codex/config.toml, settings.json).');
595
+ }
596
+ else {
597
+ for (const s of servers)
598
+ console.log(` [${s.agent}] ${s.name}${s.command ? ` -> ${s.command}` : ''}`);
599
+ const conflicts = findConflicts(servers);
600
+ if (conflicts.length)
601
+ console.log(` conflicts (same name across agents): ${conflicts.join(', ')}`);
602
+ }
603
+ console.log(' (detected, not proxied — framein never relays your MCP servers)');
604
+ }
605
+ function cmdSkills() {
606
+ console.log('Framein skills:');
607
+ for (const s of FRAMEIN_SKILLS)
608
+ console.log(` [framein] ${s.name} ${s.description}`);
609
+ const detected = detectSkillsFromDisk();
610
+ if (detected.length) {
611
+ console.log('Detected (reused from your CLIs):');
612
+ for (const s of detected)
613
+ console.log(` [${s.source}] ${s.name}${s.description ? ` — ${s.description}` : ''}`);
614
+ }
615
+ console.log(' (catalog + recommend only — skills are not cross-executed across agents)');
616
+ }
617
+ function readVersion() {
618
+ // SEA build bakes the version into a global (no package.json sits next to the executable).
619
+ const baked = globalThis.__FRAMEIN_VERSION__;
620
+ if (baked)
621
+ return `framein ${baked}`;
622
+ try {
623
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
624
+ return `framein ${pkg.version ?? '?'}`;
625
+ }
626
+ catch {
627
+ return 'framein (version unknown)';
628
+ }
629
+ }
630
+ const USAGE = {
631
+ init: 'framein initinitialize .frame/store.db and project native files',
632
+ status: 'framein status show roles, lock, decision count',
633
+ role: 'framein role set <role> <agent> | framein role list',
634
+ adr: 'framein adr add <title> | supersede <id> <title> | show <id> | list',
635
+ sync: 'framein sync [--dry-run] — re-project native files from the store',
636
+ unlock: 'framein unlock [scope] — release a stale write lock (default: global)',
637
+ mcp: 'framein mcp [patch|register|serve] detected servers / print patches / apply framein registration / run the server',
638
+ skills: 'framein skillslist framein + detected (reused) skills',
639
+ ask: 'framein ask <role> [prompt] [--show|--run|--interactive] [--trust [--ttl <dur>]] preview/record/run a headless delegation, or --interactive to drive the agent TUI; --trust adds bypass flags',
640
+ audit: 'framein audit report anomaly/thrash signals from the ledger (blocker-only)',
641
+ ledger: 'framein ledger add <kind> [target] | framein ledger list',
642
+ export: `framein export [path] — write the git-canonical snapshot (default ${SNAPSHOT_PATH})`,
643
+ import: `framein import [path] — rebuild the store from a snapshot (default ${SNAPSHOT_PATH})`,
644
+ trust: 'framein trust <agent> [--ttl <dur>] preview the per-agent permission-bypass flags + time-box (does NOT auto-enable)',
645
+ lobby: 'framein lobbyoptional interactive switchboard (also opens when you run bare `framein` in a terminal): run verbs inline, /lead <agent> to switch, /go to hand the terminal to the lead native TUI (framein pauses; resumes on exit). Zero-dep; simultaneous overlay needs node-pty (optional, ADR-0010)',
646
+ shell: 'framein shell alias for `framein lobby`, kept for back-compat (not shown in the command list).',
647
+ integrations: 'framein integrations list | show | install | uninstall <claude|codex|gemini|all> [--write] generate logic-less /fr:* (Claude/Gemini) + $fr-* (Codex skill) wrappers that call `framein <verb> --json`',
648
+ doctor: 'framein doctordetect agent CLIs on PATH + count installed wrappers',
649
+ setup: 'framein setup doctor + a wrapper-install recommendation for detected CLIs',
650
+ task: 'framein task start <goal> | show | amend <goal|preserve|acceptance|protected|nongoal> <value> the Task Contract (what "done" means)',
651
+ start: 'framein start <goal> start a Task Contract (alias of `framein task start`)',
652
+ verify: 'framein verifyrun build/test validation and check it against the Task Contract (informational)',
653
+ ship: 'framein shipthe enforced Validation Gate: READY/WARNING summary + commit/deploy guidance (exit 1 if hard validation fails)',
654
+ risk: 'framein riskBlast Radius Guard: assess changed files for sensitive code (auth/payment/migration/secrets/deploy/deps) + required gates',
655
+ route: 'framein route explain [role] show which agent would take a role in THIS repo and why (repo-local routing)',
656
+ stats: 'framein statsrepo-local agent performance derived from the ledger',
657
+ recipe: 'framein recipe list | show <name> | compile <name> <agent> vendor-neutral task protocols compiled to each CLI',
658
+ rescue: 'framein rescue [--run] — if the ledger shows a repair loop, surface signals + 3 options (never auto-acts); --run asks the reviewer to diagnose (read-only)',
659
+ checkpoint: 'framein checkpoint [label] — record the current git commit as a known-good (green) state',
660
+ rewind: 'framein rewind [--force] preview (or with --force, execute) git reset to the last checkpoint',
661
+ pause: 'framein pausesave an auto-generated Task Capsule (resume state) from the store + git',
662
+ resume: 'framein resumeprint the saved capsule (or rebuild one) to continue without a manual handoff',
663
+ capsule: 'framein capsule show — render the current Task Capsule',
664
+ challenge: 'framein challenge "<proposal>" [--run] | --block "<claim>" [--require "<change>"] | --accept | --show bounded reviewer debate; --run asks for a reviewer verdict, one lead response, and a decision brief',
665
+ decide: 'framein decide accept|reject [text] the lead resolves the open debate',
666
+ debt: 'framein debtVibe Debt Delta: what THIS change added (deps/TODOs/lines), not the whole codebase',
667
+ explain: 'framein explain [--run] — Ownership Brief skeleton (changed/test/rollback filled); --run has the explainer agent complete the narrative',
668
+ };
669
+ // --- Validation Gate helpers (F-LOOP-2): run the project's own build/test and collect results ---
670
+ function pkgHasScript(name) {
671
+ try {
672
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
673
+ return typeof pkg.scripts?.[name] === 'string';
674
+ }
675
+ catch {
676
+ return false;
677
+ }
678
+ }
679
+ function runShell(command) {
680
+ // commands are fixed strings (no user input); shell:true resolves npm.cmd/git on Windows.
681
+ const res = spawnSync(command, { encoding: 'utf8', shell: true });
682
+ if (res.error)
683
+ return { exitCode: -1, output: res.error.message };
684
+ return { exitCode: res.status ?? -1, output: (res.stdout ?? '') + (res.stderr ?? '') };
685
+ }
686
+ function gitDiff() {
687
+ const res = spawnSync('git', ['diff', 'HEAD'], { encoding: 'utf8' });
688
+ return res.status === 0 ? (res.stdout ?? '') : '';
689
+ }
690
+ function gitChangedFiles() {
691
+ const isRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], { encoding: 'utf8' });
692
+ if (isRepo.status !== 0)
693
+ return [];
694
+ const hasHead = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], { encoding: 'utf8' }).status === 0;
695
+ const commands = hasHead
696
+ ? [['diff', '--name-only', 'HEAD', '--'], ['ls-files', '--others', '--exclude-standard']]
697
+ : [['ls-files', '--cached', '--others', '--exclude-standard']];
698
+ const files = new Set();
699
+ for (const args of commands) {
700
+ const res = spawnSync('git', args, { encoding: 'utf8' });
701
+ if (res.status !== 0)
702
+ continue;
703
+ for (const line of (res.stdout ?? '').split('\n')) {
704
+ const file = line.trim();
705
+ if (file)
706
+ files.add(file.replace(/\\/g, '/'));
707
+ }
708
+ }
709
+ return [...files].sort();
710
+ }
711
+ function collectEvidence() {
712
+ const bundle = {};
713
+ if (pkgHasScript('build')) {
714
+ const r = runShell('npm run build');
715
+ bundle.build = { command: 'npm run build', exitCode: r.exitCode };
716
+ }
717
+ if (pkgHasScript('test')) {
718
+ const r = runShell('npm test');
719
+ bundle.tests = { command: 'npm test', summary: parseTestSummary(r.output), exitCode: r.exitCode };
720
+ }
721
+ const changed = gitChangedFiles();
722
+ if (changed.length)
723
+ bundle.changedFiles = changed;
724
+ return bundle;
725
+ }
726
+ function cmdVerify(args = []) {
727
+ withStore((store) => {
728
+ withCliWriteLock(store, 'evidence', () => {
729
+ const bundle = collectEvidence();
730
+ store.setMemory('evidence', 'last', bundle); // recorded so `ship`/capsule can reuse it
731
+ const result = gate(store.getTaskContract(), bundle);
732
+ if (wantsJson(args)) {
733
+ emitJson('verify', { ready: result.ready, status: result.ready ? 'ready' : 'not_ready', checks: result.checks, warnings: result.warnings });
734
+ return;
735
+ }
736
+ console.log(renderGate(result, cliUi()));
737
+ });
738
+ });
739
+ }
740
+ function cmdShip(args = []) {
741
+ withStore((store) => {
742
+ withCliWriteLock(store, 'evidence', () => {
743
+ const bundle = collectEvidence();
744
+ store.setMemory('evidence', 'last', bundle);
745
+ const result = gate(store.getTaskContract(), bundle);
746
+ // Blast Radius Guard (F-LOOP-6): raise the gate when the change touches sensitive code.
747
+ const blast = assessBlastRadius(bundle.changedFiles ?? []);
748
+ if (wantsJson(args)) {
749
+ emitJson('ship', {
750
+ ready: result.ready, status: result.ready ? 'ready' : 'not_ready',
751
+ checks: result.checks, warnings: result.warnings,
752
+ safeToCommit: result.ready, safeToDeploy: result.ready ? 'requires_human' : false,
753
+ risk: blast.level, requiredGates: blast.requiredGates,
754
+ });
755
+ store.setMemory('risk', 'last', blast.level);
756
+ if (!result.ready)
757
+ process.exitCode = 1;
758
+ return;
759
+ }
760
+ console.log(renderShip(result, cliUi()));
761
+ if (blast.level !== 'low') {
762
+ console.log('\n' + renderBlast(blast, cliUi()));
763
+ const t = riskTransition(store.getMemory('risk', 'last'), blast.level);
764
+ if (t)
765
+ console.log(t);
766
+ }
767
+ store.setMemory('risk', 'last', blast.level);
768
+ if (!result.ready)
769
+ process.exitCode = 1; // ship is the enforced gate; verify is informational
770
+ });
771
+ });
772
+ }
773
+ // --- Repo-local Routing (F-LOOP-7): route by this repo's results, and explain the choice ---
774
+ function cmdRoute(args) {
775
+ if (args[0] !== 'explain')
776
+ fail('Usage: framein route explain [role]');
777
+ const roleArg = args.slice(1).find((a) => !a.startsWith('--'));
778
+ if (roleArg && !isRole(roleArg))
779
+ fail(`Unknown role '${roleArg}'. Valid: ${ROLES.join(', ')}`);
780
+ const role = roleArg ?? 'reviewer';
781
+ withStore((store) => {
782
+ const e = explainRoute(role, { authMode: {} }, computeRepoStats(store.listLedger()));
783
+ if (wantsJson(args)) {
784
+ emitJson('route', { role: e.role, agent: e.agent, reasons: e.reasons, alternative: e.alternative ?? null });
785
+ return;
786
+ }
787
+ console.log(renderRouteExplain(e, cliUi()));
788
+ });
789
+ }
790
+ function cmdStats(args = []) {
791
+ withStore((store) => {
792
+ const stats = computeRepoStats(store.listLedger());
793
+ if (wantsJson(args)) {
794
+ emitJson('stats', { stats });
795
+ return;
796
+ }
797
+ console.log(renderStats(stats, cliUi()));
798
+ });
799
+ }
800
+ // --- Vibe Debt Delta + Ownership Brief (F-LOOP-9/10) ---
801
+ function cmdDebt(args = []) {
802
+ const d = parseDiffDebt(gitDiff());
803
+ if (wantsJson(args)) {
804
+ emitJson('debt', { addedLines: d.addedLines, removedLines: d.removedLines, addedDeps: d.addedDeps, todos: d.todos });
805
+ return;
806
+ }
807
+ console.log(renderDebt(d, cliUi()));
808
+ }
809
+ function cmdExplain(args = []) {
810
+ withStore((store) => {
811
+ const cp = store.getMemory('checkpoint', 'last');
812
+ const brief = {
813
+ goal: store.getTaskContract()?.goal,
814
+ changedFiles: gitChangedFiles(),
815
+ testCommand: pkgHasScript('test') ? 'npm test' : undefined,
816
+ lastGreen: cp?.sha,
817
+ };
818
+ const skeleton = ownershipBrief(brief);
819
+ if (!args.includes('--run')) {
820
+ if (wantsJson(args)) {
821
+ emitJson('explain', { ...brief, skeleton });
822
+ return;
823
+ }
824
+ console.log(skeleton);
825
+ console.log('\n(the narrative sections are the explainer role’s live job — run `framein explain --run`)');
826
+ return;
827
+ }
828
+ const explainer = store.getRole('explainer') ?? 'gemini'; // live: explainer fills the narrative
829
+ console.log(`Asking ${explainer} to complete the ownership brief…\n`);
830
+ const r = spawnAgentText(explainer, `You are the explainer. Complete the sections marked "(for the explainer role to fill)" in this ownership brief, based on the repository. Return the full completed brief.\n\n${skeleton}`);
831
+ if (wantsJson(args)) {
832
+ emitJson('explain', { ...brief, agent: explainer, ok: r.ok, text: r.text || skeleton });
833
+ return;
834
+ }
835
+ console.log(r.text || skeleton);
836
+ if (r.ok && r.text)
837
+ store.setMemory('brief', 'last', { agent: explainer, ts: new Date().toISOString() });
838
+ });
839
+ }
840
+ // --- Frame Recipe (F-LOOP-8): vendor-neutral protocols, compiled to each CLI (static, no store) ---
841
+ function cmdRecipe(args) {
842
+ const sub = args[0] ?? 'list';
843
+ if (sub === 'list') {
844
+ console.log('Recipes (vendor-neutral task protocols, compiled to each CLI — not cross-executed):');
845
+ for (const r of listRecipes())
846
+ console.log(` ${r.name} — trigger: ${r.trigger}, ${r.steps.length} steps`);
847
+ return;
848
+ }
849
+ if (sub === 'show') {
850
+ const r = getRecipe(args[1] ?? '');
851
+ if (!r)
852
+ fail(`Unknown recipe '${args[1] ?? ''}'. Try: framein recipe list`);
853
+ console.log(renderRecipe(r, cliUi()));
854
+ return;
855
+ }
856
+ if (sub === 'compile') {
857
+ const r = getRecipe(args[1] ?? '');
858
+ if (!r)
859
+ fail(`Unknown recipe '${args[1] ?? ''}'. Try: framein recipe list`);
860
+ const agent = args[2];
861
+ if (!agent || !isAgent(agent))
862
+ fail(`Usage: framein recipe compile <name> <${AGENTS.join('|')}>`);
863
+ console.log(compileRecipe(r, agent));
864
+ return;
865
+ }
866
+ fail(`Unknown 'recipe' subcommand '${sub}'. Use: list | show <name> | compile <name> <agent>`);
867
+ }
868
+ function cmdRisk(args = []) {
869
+ withStore((store) => {
870
+ const a = assessBlastRadius(gitChangedFiles());
871
+ const prev = store.getMemory('risk', 'last');
872
+ store.setMemory('risk', 'last', a.level);
873
+ if (wantsJson(args)) {
874
+ emitJson('risk', { level: a.level, hits: a.hits, requiredGates: a.requiredGates });
875
+ return;
876
+ }
877
+ console.log(renderBlast(a, cliUi()));
878
+ const t = riskTransition(prev, a.level);
879
+ if (t)
880
+ console.log(t);
881
+ });
882
+ }
883
+ // --- Rescue Mode + checkpoints (F-LOOP-3) ---
884
+ function gitHead() {
885
+ const res = spawnSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' });
886
+ return res.status === 0 && res.stdout ? res.stdout.trim() : null;
887
+ }
888
+ /** Spawn an agent headlessly and return its free-text output (rescue diagnosis, ownership brief).
889
+ * Same shell+stdin model as runDelegated (prompt off argv). Needs the real CLI + its trust flag
890
+ * for any tool use; plain text generation works without it. */
891
+ function spawnAgentText(agent, prompt) {
892
+ const inv = buildInvocation(agent, prompt);
893
+ const res = spawnSync(invocationCommand(inv), { input: inv.stdin, encoding: 'utf8', shell: true });
894
+ return { ok: res.status === 0 && !res.error, text: (res.stdout ?? '').trim() || (res.stderr ?? '').trim() };
895
+ }
896
+ function cmdRescue(args = []) {
897
+ withStore((store) => {
898
+ const signals = detectThrash(store.listLedger());
899
+ const cp = store.getMemory('checkpoint', 'last');
900
+ const reviewer = store.getRole('reviewer');
901
+ const report = buildRescue(signals, { lastGreen: cp, reviewer });
902
+ if (wantsJson(args)) {
903
+ emitJson('rescue', { triggered: report.triggered, signals: report.signals, lastGreen: report.lastGreen ?? null, options: report.options });
904
+ return;
905
+ }
906
+ console.log(renderRescue(report, cliUi()));
907
+ if (args.includes('--run') && report.triggered) { // option A, live: reviewer diagnoses (no edits)
908
+ const rev = reviewer ?? 'codex';
909
+ const ctx = signals.map((s) => `- ${s.message}`).join('\n');
910
+ console.log(`\nAsking ${rev} to diagnose (read-only)…`);
911
+ const r = spawnAgentText(rev, `You are the reviewer diagnosing a repair loop. Do NOT edit code. Signals:\n${ctx}\n\nGive a short likely root cause and the single next action. Be terse.`);
912
+ console.log(r.text || '(no diagnosis returned)');
913
+ if (r.ok && r.text)
914
+ store.setMemory('rescue', 'last', { agent: rev, diagnosis: r.text.slice(0, 500), ts: new Date().toISOString() });
915
+ }
916
+ });
917
+ }
918
+ function cmdCheckpoint(args) {
919
+ const label = args.filter((a) => !a.startsWith('--')).join(' ').trim();
920
+ const sha = gitHead();
921
+ if (!sha)
922
+ fail('Not a git repo (or no commits). `framein checkpoint` records the current commit as a known-good state.');
923
+ withStore((store) => {
924
+ store.setMemory('checkpoint', 'last', { sha, label });
925
+ store.appendLedger('checkpoint', sha.slice(0, 7), label);
926
+ console.log(`Checkpoint recorded: ${sha.slice(0, 7)}${label ? ` (${label})` : ''}. Return here with \`framein rewind\`.`);
927
+ });
928
+ }
929
+ function cmdRewind(args) {
930
+ const force = args.includes('--force');
931
+ withStore((store) => {
932
+ const cp = store.getMemory('checkpoint', 'last');
933
+ if (!cp)
934
+ fail('No checkpoint recorded. Run `framein checkpoint` at a known-good state first.');
935
+ if (!force) {
936
+ console.log(`would rewind to ${cp.sha.slice(0, 7)}${cp.label ? ` (${cp.label})` : ''}:`);
937
+ console.log(` git reset --hard ${cp.sha}`);
938
+ console.log(' ⚠ destructive — discards uncommitted changes. Re-run with --force to execute.');
939
+ return;
940
+ }
941
+ const res = spawnSync('git', ['reset', '--hard', cp.sha], { encoding: 'utf8' });
942
+ if (res.status !== 0)
943
+ fail(`git reset failed: ${(res.stderr ?? '').trim()}`);
944
+ store.appendLedger('rewind', cp.sha.slice(0, 7), cp.label ?? '');
945
+ console.log(`Rewound to ${cp.sha.slice(0, 7)}.`);
946
+ });
947
+ }
948
+ // --- Task Capsule (F-LOOP-4): assemble a resume capsule from what the store + git already hold ---
949
+ function gitBranch() {
950
+ const res = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { encoding: 'utf8' });
951
+ return res.status === 0 && res.stdout ? res.stdout.trim() : undefined;
952
+ }
953
+ function gatherCapsule(store) {
954
+ const contract = store.getTaskContract();
955
+ const decisions = store.listAdrs().filter((a) => !store.isSuperseded(a.id)).slice(-5).map((a) => ({ id: a.id, title: a.title }));
956
+ const cp = store.getMemory('checkpoint', 'last');
957
+ const ev = store.getMemory('evidence', 'last');
958
+ const del = store.getMemory('delegation', 'last');
959
+ const handoff = store.getMemory('handoff', 'next');
960
+ const debate = store.getMemory('debate', 'current');
961
+ const openDebate = debate ? debateStatus(debate).state !== 'resolved' : false;
962
+ const changed = gitChangedFiles();
963
+ return buildCapsule({
964
+ goal: contract?.goal,
965
+ contract,
966
+ decisions,
967
+ branch: gitBranch(),
968
+ lastGreen: cp?.sha,
969
+ changedFiles: changed,
970
+ testSummary: ev?.tests?.summary ?? null,
971
+ ledger: store.listLedger(),
972
+ lastDelegation: del ? { agent: del.agent, ok: del.ok } : undefined,
973
+ handoffTarget: handoff && isAgent(handoff) ? handoff : undefined,
974
+ openDebate,
975
+ });
976
+ }
977
+ function cmdPause() {
978
+ withStore((store) => {
979
+ const c = gatherCapsule(store);
980
+ store.setMemory('capsule', 'last', c);
981
+ console.log('Paused — task capsule saved (resume with `framein resume`):\n');
982
+ console.log(renderCapsule(c, cliUi()));
983
+ });
984
+ }
985
+ function cmdResume() {
986
+ withStore((store) => {
987
+ const saved = store.getMemory('capsule', 'last');
988
+ const c = saved ?? gatherCapsule(store);
989
+ console.log(saved ? 'Resuming from the saved capsule:\n' : 'No saved capsule — rebuilt fresh from the store:\n');
990
+ console.log(renderCapsule(c, cliUi()));
991
+ console.log('\nRead this + the repo to continue. No manual handoff needed.');
992
+ });
993
+ }
994
+ function cmdCapsule(args) {
995
+ const sub = args[0] ?? 'show';
996
+ if (sub === 'show') {
997
+ withStore((store) => console.log(renderCapsule(gatherCapsule(store), cliUi())));
998
+ return;
999
+ }
1000
+ // `capsule <agent>` arms a handoff: carry the capsule to that model. Exiting the current agent triggers
1001
+ // the switch (launchLeadTui consumes it); framein never pushes into a TUI (ADR-0009) — the next lead pulls.
1002
+ if (isAgent(sub)) {
1003
+ withStore((store) => { store.setMemory('handoff', 'next', sub); store.appendLedger('handoff', sub); });
1004
+ console.log(`↪ Handoff to ${sub} armed. Exit this agent (Ctrl-D) framein switches to ${sub}, which loads the capsule.`);
1005
+ return;
1006
+ }
1007
+ fail(`Unknown 'capsule' target '${sub}'. Use: capsule show | capsule <${AGENTS.join('|')}>`);
1008
+ }
1009
+ // --- Native command wrappers (ADR-0010/0011): generate/install/remove /fr:* + $fr-* shims ---
1010
+ const WRAP_HOSTS = ['claude', 'codex', 'gemini'];
1011
+ function resolveHosts(arg) {
1012
+ if (!arg || arg === 'all' || arg.startsWith('--'))
1013
+ return WRAP_HOSTS;
1014
+ if (!WRAP_HOSTS.includes(arg))
1015
+ fail(`Unknown host '${arg}'. Use: ${WRAP_HOSTS.join(' | ')} | all`);
1016
+ return [arg];
1017
+ }
1018
+ function cliInstalled(c) {
1019
+ const r = spawnSync(c, ['--version'], { encoding: 'utf8', shell: true });
1020
+ return r.status === 0 && !r.error;
1021
+ }
1022
+ function legacyWrapperPaths(host) {
1023
+ if (host !== 'codex')
1024
+ return [];
1025
+ return WRAP_VERBS.map((v) => `.codex/skills/fr-${v.verb}/SKILL.md`);
1026
+ }
1027
+ function cmdIntegrations(args) {
1028
+ const sub = args[0] ?? 'list';
1029
+ if (sub === 'list') {
1030
+ console.log(`framein wrappers — namespace 'fr', verbs: ${WRAP_VERBS.map((v) => v.verb).join(', ')}`);
1031
+ for (const h of WRAP_HOSTS) {
1032
+ const present = wrapperFiles(h).filter((f) => existsSync(f.path)).length;
1033
+ const pattern = h === 'codex'
1034
+ ? '.agents/skills/fr-<verb>/SKILL.md'
1035
+ : wrapperFiles(h)[0].path.replace(/[^/]+$/, '*');
1036
+ console.log(` ${h.padEnd(7)} ${pattern} (${present}/${WRAP_VERBS.length} installed)`);
1037
+ }
1038
+ console.log('Install: framein integrations install <claude|codex|gemini|all> --write');
1039
+ return;
1040
+ }
1041
+ const hosts = resolveHosts(args[1]);
1042
+ if (sub === 'show') {
1043
+ for (const h of hosts)
1044
+ for (const f of wrapperFiles(h, WRAPPER_BIN)) {
1045
+ console.log(`# ${f.path}`);
1046
+ console.log(f.content);
1047
+ }
1048
+ return;
1049
+ }
1050
+ if (sub === 'install') {
1051
+ const write = args.includes('--write');
1052
+ let n = 0;
1053
+ for (const h of hosts)
1054
+ for (const f of wrapperFiles(h, WRAPPER_BIN)) {
1055
+ if (write) {
1056
+ mkdirSync(dirname(f.path), { recursive: true });
1057
+ writeFileSync(f.path, f.content);
1058
+ console.log(`wrote ${f.path}`);
1059
+ }
1060
+ else
1061
+ console.log(`would write ${f.path}`);
1062
+ n++;
1063
+ }
1064
+ console.log(write
1065
+ ? `Installed ${n} wrapper(s). Try /fr:verify (Claude/Gemini) or $fr-verify (Codex).`
1066
+ : `# preview of ${n} file(s) — re-run with --write. Wrappers are logic-less: they call \`framein <verb> --json\`.`);
1067
+ return;
1068
+ }
1069
+ if (sub === 'uninstall') {
1070
+ let n = 0;
1071
+ for (const h of hosts)
1072
+ for (const f of wrapperFiles(h)) {
1073
+ if (existsSync(f.path) && readFileSync(f.path, 'utf8').includes(PROVENANCE)) {
1074
+ rmSync(f.path);
1075
+ console.log(`removed ${f.path}`);
1076
+ n++;
1077
+ }
1078
+ }
1079
+ for (const h of hosts)
1080
+ for (const p of legacyWrapperPaths(h)) {
1081
+ if (existsSync(p) && readFileSync(p, 'utf8').includes(PROVENANCE)) {
1082
+ rmSync(p);
1083
+ console.log(`removed legacy ${p}`);
1084
+ n++;
1085
+ }
1086
+ }
1087
+ console.log(`Removed ${n} framein wrapper(s).`);
1088
+ return;
1089
+ }
1090
+ fail(`Unknown 'integrations' subcommand '${sub}'. Use: list | show | install | uninstall`);
1091
+ }
1092
+ function cmdDoctor() {
1093
+ console.log('framein doctor environment check');
1094
+ for (const c of WRAP_HOSTS)
1095
+ console.log(` CLI ${c.padEnd(7)} ${cliInstalled(c) ? 'installed' : 'not found'}`);
1096
+ for (const h of WRAP_HOSTS) {
1097
+ const present = wrapperFiles(h).filter((f) => existsSync(f.path)).length;
1098
+ console.log(` wrappers ${h.padEnd(7)} ${present}/${WRAP_VERBS.length} (framein integrations install ${h} --write)`);
1099
+ }
1100
+ console.log(' note: Codex wrappers are repo skills in .agents/skills/fr-<verb>/; invoke them with `$fr-verify`.');
1101
+ console.log(" note: bare-name clashes are avoided by the 'fr' namespace (/fr:verify, $fr-verify).");
1102
+ }
1103
+ function cmdSetup() {
1104
+ cmdDoctor();
1105
+ const detected = WRAP_HOSTS.filter(cliInstalled);
1106
+ const missing = detected.filter((h) => wrapperFiles(h).some((f) => !existsSync(f.path)));
1107
+ console.log('');
1108
+ if (detected.length === 0)
1109
+ console.log('No agent CLI detected on PATH. Install claude/codex/gemini, then: framein integrations install all --write');
1110
+ else if (missing.length === 0)
1111
+ console.log('All detected agent wrappers are installed. Use /fr:verify (Claude/Gemini) or $fr-verify (Codex) inside your agent.');
1112
+ else
1113
+ console.log(`Next: framein integrations install ${missing.join(' ')} --write (then use /fr:verify in your agent)`);
1114
+ }
1115
+ // --- Optional interactive `framein` lobby (ADR-0010, layer 4): zero-dep readline switchboard ---
1116
+ /** Is the agent's CLI reachable on PATH? Preflight before a /go hand-over (avoids a confusing failure). */
1117
+ function agentAvailable(agent) {
1118
+ const r = spawnSync(interactiveCommand(agent), ['--version'], { encoding: 'utf8', shell: true });
1119
+ return r.status === 0 && !r.error;
1120
+ }
1121
+ function launchLeadTui(state, agent, interactive, prompt, caps, pause, resume, initialPrompt) {
1122
+ const ui = painter(caps);
1123
+ state.lead = agent;
1124
+ if (prompt)
1125
+ console.log(ui.tone(`(note: a prompt isn't seeded into the native TUI paste it once ${agent} opens)`, 'muted'));
1126
+ if (!interactive) {
1127
+ console.log(ui.tone(`(skipped launching ${agent}: the lobby needs a TTY to hand over the native UI)`, 'muted'));
1128
+ return;
1129
+ }
1130
+ // Preflight: don't hand the terminal to a CLI that isn't installed (would fail confusingly under inherit).
1131
+ if (!agentAvailable(agent)) {
1132
+ const install = {
1133
+ claude: 'claude.ai/code',
1134
+ codex: 'npm i -g @openai/codex',
1135
+ gemini: 'npm i -g @google/gemini-cli (then set GEMINI_API_KEY)',
1136
+ };
1137
+ console.error(ui.tone(`${agent} not found on PATH. Install its CLI first: ${install[agent]}`, 'danger'));
1138
+ console.error(ui.tone(`framein drives the ${agent} CLI — your API key / login lives in that CLI, framein never handles it.`, 'muted'));
1139
+ return;
1140
+ }
1141
+ // Context card + enter event: carry intent INTO the native UI; we surface state, never scrape the TUI (ADR-0009).
1142
+ let entered = false;
1143
+ let resumeSession = false; // re-entry → resume the agent's own last session (continuity)
1144
+ if (existsSync(DB_PATH)) {
1145
+ try {
1146
+ const store = Store.open(DB_PATH);
1147
+ try {
1148
+ // Have we handed off to THIS agent here before? Then continue its session instead of starting fresh.
1149
+ resumeSession = store.listLedger().some((e) => (e.kind === 'enter' || e.kind === 'return') && e.target === agent);
1150
+ const cp = store.getMemory('checkpoint', 'last');
1151
+ const rows = handoffCardRows({
1152
+ lead: agent,
1153
+ goal: store.getTaskContract()?.goal,
1154
+ reviewer: store.getRole('reviewer') ?? undefined,
1155
+ lastGreen: cp ? `${cp.sha.slice(0, 7)}${cp.label ? ` (${cp.label})` : ''}` : undefined,
1156
+ blocker: detectThrash(store.listLedger())[0]?.message,
1157
+ });
1158
+ console.log(renderFrame(`HANDOFF ${agent}`, ['Intent in · Validation in · Drift out'], { ui, unicode: caps.unicode, columns: caps.columns }));
1159
+ console.log(renderKeyVals(rows, ui));
1160
+ store.appendLedger('enter', agent);
1161
+ entered = true;
1162
+ }
1163
+ finally {
1164
+ store.close();
1165
+ }
1166
+ }
1167
+ catch { /* no/unreadable store — just hand over */ }
1168
+ }
1169
+ const trusted = !!state.trustUntil && state.trustUntil > Date.now();
1170
+ const trustFlags = trusted ? trustPlan(agent).flags : [];
1171
+ if (initialPrompt)
1172
+ console.log(ui.tone(` handoff prompt seeded; ${agent} should pull the capsule first.`, 'muted'));
1173
+ console.log(ui.tone(`→ handing the terminal to ${agent} — framein is paused.`, 'brand'));
1174
+ if (resumeSession)
1175
+ console.log(ui.tone(` ↻ resuming your previous ${agent} session (continuity).`, 'muted'));
1176
+ if (trusted)
1177
+ console.log(ui.tone(` ⚠ trust ON — ${agent} runs WITHOUT approval prompts (${trustFlags.join(' ')}).`, 'danger'));
1178
+ console.log(ui.tone(` to come back to the lobby, exit ${agent} (Ctrl-D). ('/go' is a lobby command — it won't return you.)`, 'muted'));
1179
+ pause();
1180
+ // trustFlags are placed per-agent by interactiveCommand (codex needs them BEFORE its `resume` subcommand).
1181
+ const res = spawnSync(interactiveCommand(agent, resumeSession, trustFlags, initialPrompt), { stdio: 'inherit', shell: true });
1182
+ resume();
1183
+ if (res.error)
1184
+ console.error(ui.tone(`Could not launch ${agent}: ${res.error.message}`, 'danger'));
1185
+ // Return event + recap + auto-handoff: if the agent armed `capsule <next>` before exiting, switch to it.
1186
+ let nextLead;
1187
+ if (entered && existsSync(DB_PATH)) {
1188
+ try {
1189
+ const s = Store.open(DB_PATH);
1190
+ try {
1191
+ s.appendLedger('return', agent);
1192
+ const pend = s.getMemory('handoff', 'next');
1193
+ if (pend) {
1194
+ s.deleteMemory('handoff', 'next');
1195
+ if (isAgent(pend) && pend !== agent)
1196
+ nextLead = pend;
1197
+ } // one-shot
1198
+ }
1199
+ finally {
1200
+ s.close();
1201
+ }
1202
+ }
1203
+ catch { /* ignore */ }
1204
+ }
1205
+ console.log(ui.tone(`← back in the framein lobby (lead: ${state.lead}). \`verify\` to re-check · \`status\` for state.`, 'muted'));
1206
+ if (nextLead) {
1207
+ console.log(ui.tone(`↪ handoff: switching to ${nextLead} (carrying the capsule)…`, 'brand'));
1208
+ state.lead = nextLead;
1209
+ launchLeadTui(state, nextLead, interactive, undefined, caps, pause, resume, HANDOFF_START_PROMPT); // chain; the new lead pulls the capsule
1210
+ }
1211
+ }
1212
+ /** The live status block shown on shell entry (style guide §8.1/§18): who leads, the contract, sync. */
1213
+ function shellStatusRows(state) {
1214
+ const branch = gitBranch();
1215
+ const rows = [['project', `${basename(process.cwd())}${branch ? ` · ${branch}` : ''}`], ['lead', state.lead]];
1216
+ if (existsSync(DB_PATH)) {
1217
+ try {
1218
+ const store = Store.open(DB_PATH);
1219
+ try {
1220
+ const rev = store.getRole('reviewer');
1221
+ if (rev)
1222
+ rows.push(['reviewer', rev]);
1223
+ rows.push(['task', store.getTaskContract()?.goal ?? 'no active contract']);
1224
+ }
1225
+ finally {
1226
+ store.close();
1227
+ }
1228
+ }
1229
+ catch { /* show what we have */ }
1230
+ }
1231
+ else {
1232
+ rows.push(['task', 'run `init` to start']);
1233
+ }
1234
+ return rows;
1235
+ }
1236
+ /** The lobby's starting lead = the store's `implementer` role (if set), else the routing default.
1237
+ * Without this the fr(<lead>) prompt and the status row would show a hardcoded 'claude' (ADR-E fix). */
1238
+ function initialLead() {
1239
+ if (existsSync(DB_PATH)) {
1240
+ try {
1241
+ const store = Store.open(DB_PATH);
1242
+ try {
1243
+ const impl = store.getRole('implementer');
1244
+ if (impl && isAgent(impl))
1245
+ return impl;
1246
+ }
1247
+ finally {
1248
+ store.close();
1249
+ }
1250
+ }
1251
+ catch { /* unreadable store fall through */ }
1252
+ }
1253
+ return 'claude';
1254
+ }
1255
+ /** Generic zero-dep arrow-key picker: a raw-mode keypress loop driving the pure select.ts reducer.
1256
+ * TTY only (callers guard). Restores raw mode + cursor on EVERY exit path — including an unexpected
1257
+ * process exit (the `exit` safety hook) — so the terminal is never left dirty. Returns the chosen
1258
+ * value, or null if cancelled (Esc / Ctrl-C). One primitive powers /lead and the first-run wizard. */
1259
+ function promptSelect(header, items, caps, startIndex = 0, clearOnExit = false) {
1260
+ const ui = painter(caps);
1261
+ let state = startIndex > 0 ? { ...initSelect(items), index: startIndex } : initSelect(items);
1262
+ const out = process.stdout;
1263
+ const marker = caps.unicode ? '›' : '>';
1264
+ let drawn = 0;
1265
+ const draw = (first) => {
1266
+ const lines = renderSelectLines(header, state, marker);
1267
+ if (!first) {
1268
+ moveCursor(out, 0, -drawn);
1269
+ clearScreenDown(out);
1270
+ }
1271
+ out.write(lines.map((l, i) => (i === 0 ? ui.tone(l, 'muted') : l)).join('\n') + '\n');
1272
+ drawn = lines.length;
1273
+ };
1274
+ return new Promise((resolve) => {
1275
+ const restore = () => { try {
1276
+ if (process.stdin.isTTY)
1277
+ process.stdin.setRawMode(false);
1278
+ out.write('\x1b[?25h');
1279
+ }
1280
+ catch { /* best effort */ } };
1281
+ emitKeypressEvents(process.stdin);
1282
+ if (process.stdin.isTTY)
1283
+ process.stdin.setRawMode(true);
1284
+ process.stdin.resume();
1285
+ out.write('\x1b[?25l'); // hide cursor (ANSI)
1286
+ process.once('exit', restore); // safety net: never leave raw mode / a hidden cursor behind
1287
+ draw(true);
1288
+ const cleanup = (result) => {
1289
+ process.stdin.off('keypress', onKey);
1290
+ process.removeListener('exit', restore);
1291
+ if (clearOnExit && drawn) {
1292
+ moveCursor(out, 0, -drawn);
1293
+ clearScreenDown(out);
1294
+ } // erase the menu
1295
+ restore();
1296
+ resolve(result);
1297
+ };
1298
+ const onKey = (_str, key) => {
1299
+ const step = reduceSelectKey(state, key);
1300
+ if (step.kind === 'cancel')
1301
+ return cleanup(null);
1302
+ if (step.kind === 'accept')
1303
+ return cleanup(step.value);
1304
+ state = step.state;
1305
+ draw(false);
1306
+ };
1307
+ process.stdin.on('keypress', onKey);
1308
+ });
1309
+ }
1310
+ /** Interactive inline `/` command palette + line editor for the lobby (TTY only; the non-TTY path uses
1311
+ * readline). Renders the prompt and the line you're typing; the moment the line starts with `/`, a
1312
+ * filterable suggestion list appears BELOW it (palette.ts). ⏎ runs exactly what you typed — it never
1313
+ * force-picks the top item; ↑/↓ opt into a suggestion and then ⏎ runs that one; Esc clears the line.
1314
+ * Raw mode is entered only while editing and always restored before returning, so /go's child process
1315
+ * inherits a clean cooked terminal and inter-command output is normal. Returns the line to run, or
1316
+ * {exit} on Ctrl-D / double Ctrl-C. One keypress loop; the decision logic is the pure reducer. */
1317
+ function readLobbyLine(state, caps) {
1318
+ const ui = painter(caps);
1319
+ const out = process.stdout;
1320
+ const arrow = caps.unicode ? '›' : '>';
1321
+ const marker = caps.unicode ? '›' : '>';
1322
+ const cols = caps.columns && caps.columns > 20 ? caps.columns : 80;
1323
+ const promptPlain = `fr(${state.lead})${arrow} `;
1324
+ const promptColored = `${ui.tone('fr', 'brand')}(${state.lead})${arrow} `;
1325
+ let ps = initPalette('');
1326
+ let below = 0; // lines drawn under the input line (suggestions + optional note)
1327
+ let note = ''; // transient hint (e.g. Ctrl-C armed), cleared on the next keystroke
1328
+ let sigintArmed = false;
1329
+ return new Promise((resolve) => {
1330
+ const restore = () => { try {
1331
+ if (process.stdin.isTTY)
1332
+ process.stdin.setRawMode(false);
1333
+ }
1334
+ catch { /* best effort */ } };
1335
+ const fit = (s) => (s.length > cols - 1 ? `${s.slice(0, cols - 2)}…` : s);
1336
+ // What the INPUT LINE shows. Filtering still keys off ps.buf (what you typed), but once you arrow
1337
+ // into the list the line mirrors the highlighted command — so the prompt shows what you're picking.
1338
+ const shownLine = () => {
1339
+ if (!ps.navigated)
1340
+ return ps.buf;
1341
+ const sugg = paletteSuggestions(ps.buf, LOBBY_PALETTE);
1342
+ return sugg.length ? sugg[Math.min(ps.index, sugg.length - 1)].cmd : ps.buf;
1343
+ };
1344
+ const draw = (first) => {
1345
+ if (!first) {
1346
+ out.write('\r');
1347
+ clearScreenDown(out);
1348
+ } // cursor is at end of input col0 + wipe down
1349
+ const shown = shownLine();
1350
+ out.write(promptColored + shown); // input line; cursor now at its end
1351
+ const plain = renderPaletteSuggestions(ps, LOBBY_PALETTE, marker).map(fit);
1352
+ const idx = Math.min(ps.index, Math.max(0, plain.length - 1));
1353
+ const lines = plain.map((ln, i) => ui.tone(ln, i === idx ? 'brand' : 'muted'));
1354
+ if (note)
1355
+ lines.push(ui.tone(fit(note), 'muted'));
1356
+ for (const ln of lines)
1357
+ out.write(`\n${ln}`);
1358
+ below = lines.length;
1359
+ if (below > 0) {
1360
+ moveCursor(out, 0, -below);
1361
+ out.write('\r');
1362
+ moveCursor(out, promptPlain.length + shown.length, 0);
1363
+ }
1364
+ };
1365
+ const finish = (result) => {
1366
+ out.write('\r');
1367
+ clearScreenDown(out); // erase the live menu
1368
+ out.write(`${promptColored}${result.kind === 'line' ? result.line : shownLine()}\n`); // echo the actual command run
1369
+ process.stdin.off('keypress', onKey);
1370
+ process.removeListener('exit', restore);
1371
+ restore();
1372
+ process.stdin.pause();
1373
+ resolve(result);
1374
+ };
1375
+ const onKey = (_str, key) => {
1376
+ const step = reducePaletteKey(ps, key, LOBBY_PALETTE);
1377
+ if (step.kind === 'submit')
1378
+ return finish({ kind: 'line', line: step.line });
1379
+ if (step.kind === 'exit')
1380
+ return finish({ kind: 'exit' });
1381
+ if (step.kind === 'sigint') {
1382
+ if (ps.buf) {
1383
+ ps = initPalette('');
1384
+ note = '';
1385
+ sigintArmed = false;
1386
+ return draw(false);
1387
+ } // first Ctrl-C clears the line
1388
+ if (sigintArmed)
1389
+ return finish({ kind: 'exit' });
1390
+ sigintArmed = true;
1391
+ note = "press Ctrl-C again (or /exit) to leave the lobby";
1392
+ return draw(false);
1393
+ }
1394
+ ps = step.state;
1395
+ note = '';
1396
+ sigintArmed = false;
1397
+ draw(false);
1398
+ };
1399
+ emitKeypressEvents(process.stdin);
1400
+ if (process.stdin.isTTY)
1401
+ process.stdin.setRawMode(true);
1402
+ process.stdin.resume();
1403
+ process.once('exit', restore);
1404
+ process.stdin.on('keypress', onKey);
1405
+ draw(true);
1406
+ });
1407
+ }
1408
+ /** /lead picker: lead agents with install/current hints, starting on the current lead. */
1409
+ function promptLeadSelect(current, caps) {
1410
+ const items = AGENTS.map((a) => ({
1411
+ value: a, label: a,
1412
+ hint: a === current ? 'current' : (agentAvailable(a) ? 'installed' : 'not on PATH'),
1413
+ }));
1414
+ const ci = Math.max(0, items.findIndex((i) => i.value === current));
1415
+ return promptSelect('pick a lead ↑↓ move · type to filter · enter select · esc cancel', items, caps, ci);
1416
+ }
1417
+ /** First-run mini-wizard (G): shown once, interactively, when there's no project here yet. Detects which
1418
+ * agent CLIs are installed and lets the user pick a default lead (Esc to skip). Returns the chosen lead,
1419
+ * or null to skip; the caller runs `init` + sets the implementer role. */
1420
+ async function firstRunWizard(caps) {
1421
+ const ui = painter(caps);
1422
+ // No separate banner — the wizard flows straight into the one lobby banner (single-stage entry).
1423
+ const installed = AGENTS.filter((a) => agentAvailable(a));
1424
+ if (installed.length === 0) {
1425
+ console.log(ui.tone('First run no agent CLI found on PATH (claude / codex / gemini).', 'muted'));
1426
+ console.log(ui.tone('You can still `init` now and add an agent later: claude claude.ai/code · codex → npm i -g @openai/codex · gemini → npm i -g @google/gemini-cli', 'muted'));
1427
+ return null;
1428
+ }
1429
+ const items = installed.map((a) => ({ value: a, label: a, hint: 'installed' }));
1430
+ // Instruction lives in the header (not a separate console.log) and clearOnExit=true erases the whole
1431
+ // picker after you choose so first run stays ONE screen: banner (pick flashes) → lobby.
1432
+ return await promptSelect('First run pick your lead agent ↑↓ · enter · esc to skip', items, caps, 0, true);
1433
+ }
1434
+ /** True when the cwd already has files (an existing project being introduced to framein), vs a fresh,
1435
+ * empty folder. Drives whether first-run init asks before touching files. `.frame` is framein's own. */
1436
+ function projectHasFiles() {
1437
+ try {
1438
+ return readdirSync('.').some((f) => f !== '.frame');
1439
+ }
1440
+ catch {
1441
+ return false;
1442
+ }
1443
+ }
1444
+ /** First run inside an EXISTING project: show exactly what init will touch and confirm before writing.
1445
+ * The key reassurance (the user's concern): framein never deletes or overwrites your files it only
1446
+ * adds/updates a marked `framein` block and preserves everything else. Returns true to proceed. TTY only. */
1447
+ async function confirmInitInExistingProject(caps) {
1448
+ const ui = painter(caps);
1449
+ console.log(ui.tone('This folder already has files here’s exactly what setting up framein will do.', 'muted'));
1450
+ console.log(ui.tone('It does NOT delete or overwrite anything: it only adds a marked `framein` block', 'brand'));
1451
+ console.log(ui.tone('and leaves the rest of each file untouched (every change shows up in `git diff`).', 'brand'));
1452
+ for (const n of ['CLAUDE.md', 'AGENTS.md', 'GEMINI.md']) {
1453
+ const what = existsSync(n)
1454
+ ? 'exists appends a managed block at the end; your content is preserved'
1455
+ : 'will be created (new file)';
1456
+ console.log(` • ${ui.tone(n.padEnd(10), 'muted')} ${ui.tone(what, 'muted')}`);
1457
+ }
1458
+ console.log(` ${ui.tone('.frame/'.padEnd(10), 'muted')} ${ui.tone('local store (rebuildable cache add to .gitignore)', 'muted')}`);
1459
+ const ans = await promptSelect('Set up framein in this project?', [
1460
+ { value: 'yes', label: 'yes', hint: 'add the framein block(s) — nothing else is touched' },
1461
+ { value: 'no', label: 'no', hint: 'skip — run `init` yourself later' },
1462
+ ], caps, 0, true);
1463
+ return ans === 'yes';
1464
+ }
1465
+ /** Lobby: after switching to an agent that's installed but has no wrappers yet, offer to install them.
1466
+ * Raw-mode confirm — the caller must have the readline interface closed (true in the /lead picker path). */
1467
+ async function offerWrappers(agent, caps) {
1468
+ if (!cliInstalled(agent))
1469
+ return; // CLI not installed → nothing to wrap
1470
+ if (wrapperFiles(agent).every((f) => existsSync(f.path)))
1471
+ return; // already installed
1472
+ const ui = painter(caps);
1473
+ const ans = await promptSelect(`Install ${agent} commands (${agent === 'codex' ? '$fr-*' : '/fr:*'})?`, [{ value: 'yes', label: 'yes', hint: 'install now' }, { value: 'no', label: 'no', hint: 'later' }], caps, 0, true);
1474
+ if (ans === 'yes')
1475
+ console.log(ui.tone(`installed ${writeWrappers(agent)} ${agent} wrapper(s)`, 'brand'));
1476
+ }
1477
+ /** Apply the path-independent lobby actions (everything EXCEPT exit / pickLead / launchLead, which need
1478
+ * the caller's I/O context). Returns true if handled. Shared by the interactive (inline-palette) loop
1479
+ * and the non-TTY readline path so the two never drift. */
1480
+ function applyLobbyCommon(action, state, caps, interactive) {
1481
+ const ui = painter(caps);
1482
+ switch (action.kind) {
1483
+ case 'noop': return true;
1484
+ case 'help':
1485
+ console.log(renderShellHelp());
1486
+ return true;
1487
+ case 'setLead':
1488
+ state.lead = action.agent;
1489
+ console.log(ui.tone(`lead ${ui.sym.next} ${state.lead}`, 'brand'));
1490
+ if (interactive && cliInstalled(action.agent) && !wrapperFiles(action.agent).every((f) => existsSync(f.path)))
1491
+ console.log(ui.tone(`tip: \`integrations install ${action.agent} --write\` adds its ${action.agent === 'codex' ? '$fr-*' : '/fr:*'} commands`, 'muted'));
1492
+ return true;
1493
+ case 'toggleTrust': {
1494
+ const now = Date.now();
1495
+ if (state.trustUntil && state.trustUntil > now) {
1496
+ state.trustUntil = undefined;
1497
+ console.log(ui.tone('trust OFF — /go runs the lead with normal per-action approval prompts.', 'brand'));
1498
+ }
1499
+ else {
1500
+ state.trustUntil = now + DEFAULT_TRUST_TTL_SEC * 1000;
1501
+ console.log(ui.tone(`⚠ trust ON for ${Math.round(DEFAULT_TRUST_TTL_SEC / 60)}m — the next /go launches the lead WITHOUT per-action approval prompts.`, 'danger'));
1502
+ console.log(ui.tone(' a worktree is NOT a sandbox: network, credentials, installs are not blocked. /trust again to disarm.', 'muted'));
1503
+ }
1504
+ return true;
1505
+ }
1506
+ case 'error':
1507
+ console.error(ui.tone(action.message, 'danger'));
1508
+ return true;
1509
+ case 'engine':
1510
+ if (action.args[0] === 'mcp' && action.args[1] === 'serve') {
1511
+ console.error('`mcp serve` would consume the lobby stdin — run it as `framein mcp serve` instead.');
1512
+ return true;
1513
+ }
1514
+ try {
1515
+ runCommand(action.args);
1516
+ }
1517
+ catch (e) {
1518
+ if (e instanceof CliError) {
1519
+ if (e.message)
1520
+ console.error(e.message);
1521
+ }
1522
+ else
1523
+ throw e;
1524
+ }
1525
+ process.exitCode = 0; // a command's failure code shouldn't doom the lobby session
1526
+ return true;
1527
+ default: return false; // exit / pickLead / launchLead — the caller's I/O context handles these
1528
+ }
1529
+ }
1530
+ let inLobby = false; // true while the lobby owns stdin → cmdTask skips its own readline (no conflict)
1531
+ function cmdShell() {
1532
+ inLobby = true;
1533
+ const interactive = Boolean(process.stdin.isTTY);
1534
+ const caps = cliCaps();
1535
+ const ui = painter(caps);
1536
+ const arrow = caps.unicode ? '›' : '>';
1537
+ process.exitCode = 0;
1538
+ // Entry is wrapped in an async flow because the first-run wizard, the inline palette editor, and the
1539
+ // /lead picker all await raw-mode input. Interactive sessions run the inline-palette lobbyLoop; non-TTY
1540
+ // (piped) sessions use the plain readline open() loop. Both end by printing "bye".
1541
+ void (async () => {
1542
+ let lead = initialLead();
1543
+ const firstRun = interactive && !existsSync(DB_PATH);
1544
+ // Banner FIRST so the first thing you see is "you're in framein". The first-run picker then opens
1545
+ // below it and ERASES itself on choice (clearOnExit), so what remains is a single lobby screen —
1546
+ // not a stacked [pick]+[welcome] two-stage view.
1547
+ if (interactive) {
1548
+ const ver = readVersion().replace(/^framein /, 'v'); // e.g. v0.0.6
1549
+ console.log(renderFrame('FRAMEIN', [`Framein by Frameout · ${ver}`, 'Intent in · Validation in · Drift out'], { ui, unicode: caps.unicode, columns: caps.columns }));
1550
+ console.log('');
1551
+ }
1552
+ let setupNote = '';
1553
+ if (firstRun) {
1554
+ const chosen = await firstRunWizard(caps); // self-clearing picker
1555
+ if (chosen) {
1556
+ // Empty folder set up silently. Existing project → show what changes + confirm first (the
1557
+ // managed block is additive and non-destructive, but we still ask before touching their files).
1558
+ const proceed = projectHasFiles() ? await confirmInitInExistingProject(caps) : true;
1559
+ if (proceed) {
1560
+ try {
1561
+ const n = cmdInit({ lead: chosen, quiet: true });
1562
+ lead = chosen;
1563
+ setupNote = ui.tone(`✓ set up · context synced${n ? ` · ${n} wrappers (/fr:* · $fr-*)` : ''}`, 'brand');
1564
+ }
1565
+ catch (e) {
1566
+ if (e instanceof CliError && e.message)
1567
+ console.error(e.message);
1568
+ }
1569
+ }
1570
+ else {
1571
+ lead = chosen; // chosen in-memory; nothing written
1572
+ setupNote = ui.tone('Skipped setup — nothing was written. Run `init` when you’re ready.', 'muted');
1573
+ }
1574
+ }
1575
+ }
1576
+ const state = { lead };
1577
+ if (interactive) {
1578
+ console.log(ui.tone('Your home base for AI coding — switch the lead agent, run a local check, or hand off with /go.', 'muted'));
1579
+ console.log('');
1580
+ console.log(renderKeyVals(shellStatusRows(state), ui));
1581
+ console.log('');
1582
+ if (setupNote)
1583
+ console.log(setupNote);
1584
+ console.log(ui.tone(existsSync(DB_PATH)
1585
+ ? 'Type / to browse commands (filters as you type · ↑↓ to pick · ⏎ runs) · /go · /help · /exit'
1586
+ : 'Not set up yet → type /init first. Then type / to browse commands · /help · /exit', 'muted'));
1587
+ }
1588
+ // Non-TTY (piped / automation) path: a plain readline loop. The interactive TTY path uses the inline
1589
+ // `/` palette editor (readLobbyLine + lobbyLoop) below. Both route through applyLobbyCommon so the
1590
+ // command behavior is identical; only the line-reading + exit/picker I/O differs.
1591
+ const open = () => {
1592
+ let sigintArmed = false;
1593
+ const iface = createInterface({ input: process.stdin, output: process.stdout, completer: lobbyCompleter });
1594
+ const next = () => { if (interactive) {
1595
+ iface.setPrompt(`${ui.tone('fr', 'brand')}(${state.lead})${arrow} `);
1596
+ iface.prompt();
1597
+ } };
1598
+ iface.on('line', (line) => {
1599
+ sigintArmed = false;
1600
+ const action = routeShellLine(line, state);
1601
+ if (applyLobbyCommon(action, state, caps, interactive)) {
1602
+ next();
1603
+ return;
1604
+ }
1605
+ switch (action.kind) {
1606
+ case 'exit':
1607
+ iface.close();
1608
+ return; // close handler prints bye
1609
+ case 'pickLead':
1610
+ console.log(`lead: ${state.lead}`);
1611
+ break; // non-TTY fallback: never block on a picker
1612
+ case 'launchLead':
1613
+ launchLeadTui(state, action.agent, interactive, action.prompt, caps, () => iface.pause(), () => iface.resume());
1614
+ break;
1615
+ }
1616
+ next();
1617
+ });
1618
+ iface.on('SIGINT', () => {
1619
+ if (sigintArmed) {
1620
+ iface.close();
1621
+ return;
1622
+ }
1623
+ sigintArmed = true;
1624
+ console.log(ui.tone("press Ctrl-C again (or type 'exit') to leave the lobby", 'muted'));
1625
+ next();
1626
+ });
1627
+ iface.on('close', () => console.log(ui.tone('bye', 'muted')));
1628
+ next();
1629
+ };
1630
+ // Interactive TTY path: read one line via the inline `/` palette editor, route it, repeat.
1631
+ const lobbyLoop = async () => {
1632
+ for (;;) {
1633
+ const r = await readLobbyLine(state, caps);
1634
+ if (r.kind === 'exit')
1635
+ break;
1636
+ const action = routeShellLine(r.line, state);
1637
+ if (applyLobbyCommon(action, state, caps, true))
1638
+ continue;
1639
+ if (action.kind === 'exit')
1640
+ break;
1641
+ if (action.kind === 'pickLead') {
1642
+ const picked = await promptLeadSelect(state.lead, caps);
1643
+ if (picked && picked !== state.lead) {
1644
+ state.lead = picked;
1645
+ console.log(ui.tone(`lead ${ui.sym.next} ${state.lead}`, 'brand'));
1646
+ await offerWrappers(picked, caps);
1647
+ }
1648
+ else
1649
+ console.log(ui.tone(`lead: ${state.lead}`, 'muted'));
1650
+ }
1651
+ else if (action.kind === 'launchLead') {
1652
+ launchLeadTui(state, action.agent, true, action.prompt, caps, () => { }, () => { });
1653
+ }
1654
+ }
1655
+ console.log(ui.tone('bye', 'muted'));
1656
+ };
1657
+ if (interactive)
1658
+ await lobbyLoop();
1659
+ else
1660
+ open();
1661
+ })();
1662
+ }
1663
+ const CONTRACT_FIELDS = ['goal', 'preserve', 'acceptance', 'protected', 'nongoal'];
1664
+ /** Loud, git-pointing notice whenever the Task Contract changes. The contract is tracked end-to-end,
1665
+ * so every change — human- OR agent-driven — must be visible and reviewable (auto-applied, never
1666
+ * silent; ADR-0012). git diff of the managed block is the audit + the undo. */
1667
+ function surfaceContractChange(what) {
1668
+ console.log(`⚠ Contract changed (${what}) — tracked end-to-end. Review/undo: git diff -- CLAUDE.md AGENTS.md GEMINI.md`);
1669
+ }
1670
+ /** Conversational `framein start` (no goal, terminal only): ask the contract fields one by one, then
1671
+ * set it. Skipped inside the lobby (it owns stdin) and in non-TTY/automation (which need an explicit goal). */
1672
+ async function runGuidedStart() {
1673
+ const ui = painter(cliCaps());
1674
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1675
+ const ask = (q) => new Promise((res) => rl.question(`${ui.tone('?', 'brand')} ${q}\n `, res));
1676
+ console.log(ui.tone('Guided start — define the contract (the definition of done). Empty goal aborts.', 'muted'));
1677
+ const answers = {};
1678
+ for (const step of GUIDED_CONTRACT_STEPS) {
1679
+ const a = (await ask(step.question)).trim();
1680
+ if (step.field === 'goal' && !a) {
1681
+ console.log(ui.tone('No goal — aborted.', 'muted'));
1682
+ rl.close();
1683
+ return;
1684
+ }
1685
+ if (a)
1686
+ answers[step.field] = a;
1687
+ }
1688
+ rl.close();
1689
+ const c = buildGuidedContract(answers);
1690
+ withStore((store) => {
1691
+ withCliWriteLock(store, 'task', () => {
1692
+ store.setTaskContract(c);
1693
+ store.appendLedger('task-start', '', c.goal.slice(0, 200));
1694
+ writeNativeFiles('.', store.getState());
1695
+ console.log(`Task contract started: ${c.goal}`);
1696
+ for (const i of contractIssues(c))
1697
+ console.log(` ⚠ ${i}`);
1698
+ surfaceContractChange('start');
1699
+ });
1700
+ });
1701
+ }
1702
+ function cmdTask(args) {
1703
+ if ((args[0] ?? 'show') === 'start' && !args.slice(1).join(' ').trim() && Boolean(process.stdin.isTTY) && !inLobby) {
1704
+ void runGuidedStart();
1705
+ return; // conversational contract builder (terminal only)
1706
+ }
1707
+ withStore((store) => {
1708
+ const sub = args[0] ?? 'show';
1709
+ if (sub === 'start') {
1710
+ const goal = args.slice(1).join(' ').trim();
1711
+ if (!goal)
1712
+ fail('Usage: framein task start <goal>');
1713
+ withCliWriteLock(store, 'task', () => {
1714
+ const c = emptyContract(goal);
1715
+ store.setTaskContract(c);
1716
+ store.appendLedger('task-start', '', goal.slice(0, 200));
1717
+ writeNativeFiles('.', store.getState()); // project the contract as standing intent
1718
+ console.log(`Task contract started: ${goal}`);
1719
+ console.log(' Add criteria: framein task amend acceptance "<...>" (fields: ' + CONTRACT_FIELDS.join('|') + ')');
1720
+ for (const i of contractIssues(c))
1721
+ console.log(` ⚠ ${i}`);
1722
+ surfaceContractChange('start');
1723
+ });
1724
+ }
1725
+ else if (sub === 'amend') {
1726
+ const field = args[1];
1727
+ const value = args.slice(2).join(' ').trim();
1728
+ if (!field || !CONTRACT_FIELDS.includes(field) || !value) {
1729
+ fail(`Usage: framein task amend <${CONTRACT_FIELDS.join('|')}> <value>`);
1730
+ }
1731
+ withCliWriteLock(store, 'task', () => {
1732
+ const cur = store.getTaskContract();
1733
+ if (!cur)
1734
+ fail('No active task contract. Run `framein task start <goal>` first.');
1735
+ store.setTaskContract(amendContract(cur, field, value));
1736
+ writeNativeFiles('.', store.getState());
1737
+ console.log(`Amended ${field}; contract re-projected to the native files.`);
1738
+ surfaceContractChange(`amend ${field}`);
1739
+ });
1740
+ }
1741
+ else if (sub === 'show') {
1742
+ const cur = store.getTaskContract();
1743
+ if (wantsJson(args)) {
1744
+ emitJson('task', { contract: cur ?? null, issues: cur ? contractIssues(cur) : [] });
1745
+ return;
1746
+ }
1747
+ if (!cur) {
1748
+ console.log('No active task contract. Run `framein task start <goal>`.');
1749
+ return;
1750
+ }
1751
+ console.log(renderContractFull(cur, cliUi()));
1752
+ for (const i of contractIssues(cur))
1753
+ console.log(` ⚠ ${i}`);
1754
+ }
1755
+ else {
1756
+ fail(`Unknown 'task' subcommand '${sub}'. Use: start | show | amend`);
1757
+ }
1758
+ });
1759
+ }
1760
+ // --- Disagreement Protocol (F-LOOP-5): bounded model-vs-model debate, lead keeps control ---
1761
+ function flagValue(args, flag) {
1762
+ const i = args.indexOf(flag);
1763
+ if (i === -1)
1764
+ return undefined;
1765
+ const parts = [];
1766
+ for (let j = i + 1; j < args.length && !args[j].startsWith('--'); j++)
1767
+ parts.push(args[j]);
1768
+ return parts.join(' ');
1769
+ }
1770
+ function cmdChallenge(args) {
1771
+ withStore((store) => {
1772
+ const reviewer = store.getRole('reviewer');
1773
+ let d = store.getMemory('debate', 'current');
1774
+ if (args.includes('--show')) {
1775
+ if (!d) {
1776
+ console.log('No open debate.');
1777
+ return;
1778
+ }
1779
+ console.log(renderDebate(d, cliUi()));
1780
+ return;
1781
+ }
1782
+ if (args.includes('--accept')) {
1783
+ if (!d)
1784
+ fail('No open debate to accept.');
1785
+ d.entries.push({ kind: 'challenge', challenge: { verdict: 'accept', by: reviewer } });
1786
+ store.setMemory('debate', 'current', d);
1787
+ store.appendLedger('challenge', 'accept');
1788
+ console.log(renderDebate(d, cliUi()));
1789
+ return;
1790
+ }
1791
+ if (args.includes('--block')) {
1792
+ if (!d)
1793
+ fail('No open debate. Start one: framein challenge "<proposal>".');
1794
+ const claim = flagValue(args, '--block') ?? '';
1795
+ const requiredChange = flagValue(args, '--require');
1796
+ d.entries.push({ kind: 'challenge', challenge: { verdict: 'challenge', claim, requiredChange, by: reviewer } });
1797
+ store.setMemory('debate', 'current', d);
1798
+ store.appendLedger('challenge', '', claim.slice(0, 80));
1799
+ console.log(renderDebate(d, cliUi()));
1800
+ return;
1801
+ }
1802
+ // --by <host>: which model is invoking (the agent wrapper passes its own host) → pick a reviewer ≠ it.
1803
+ const byIdx = args.indexOf('--by');
1804
+ const by = byIdx !== -1 ? args[byIdx + 1] : undefined;
1805
+ // proposal text = positionals, skipping flags and --by's single value.
1806
+ const positional = [];
1807
+ for (let i = 0; i < args.length; i++) {
1808
+ if (args[i] === '--by') {
1809
+ i++;
1810
+ continue;
1811
+ }
1812
+ if (args[i].startsWith('--'))
1813
+ continue;
1814
+ positional.push(args[i]);
1815
+ }
1816
+ const text = positional.join(' ').trim();
1817
+ if (!text)
1818
+ fail('Usage: framein challenge "<proposal>" [--run] | --block "<claim>" [--require "<change>"] | --accept | --show');
1819
+ const proposer = by && isAgent(by) ? by : (store.getRole('lead') ?? store.getRole('implementer'));
1820
+ d = newDebate(text, { text, by: proposer });
1821
+ store.setMemory('debate', 'current', d);
1822
+ console.log(renderDebate(d, cliUi()));
1823
+ if (args.includes('--run')) {
1824
+ const indep = independentReviewer(store, by);
1825
+ if (!indep) {
1826
+ console.log(`\n(no independent model available${by ? ` other than ${by}` : ''} — install another agent CLI, set a different \`reviewer\` role, or record manually with --block/--accept.)`);
1827
+ return;
1828
+ }
1829
+ if (by && reviewer === by)
1830
+ console.log(`(reviewer role is ${by} = the calling model using ${indep} instead so the verdict is genuinely independent.)`);
1831
+ const facts = gatherChallengeFacts(store, text, d);
1832
+ const verdict = runReviewerChallenge(store, d, indep, facts);
1833
+ if (!verdict)
1834
+ return;
1835
+ const lead = proposer && isAgent(proposer) ? proposer : undefined;
1836
+ const leadResponse = verdict.verdict === 'challenge' && lead && lead !== indep
1837
+ ? runLeadChallengeResponse(store, d, lead, facts, verdict)
1838
+ : undefined;
1839
+ console.log('\n' + renderDecisionBrief({ proposal: text, reviewer: indep, lead, verdict, leadResponse }));
1840
+ return;
1841
+ }
1842
+ const hint = independentReviewer(store, by);
1843
+ if (hint)
1844
+ console.log(`\n(get an independent verdict from ${hint} with --run, or record one with --block/--accept)`);
1845
+ });
1846
+ }
1847
+ function gatherChallengeFacts(store, proposal, debate) {
1848
+ const changed = gitChangedFiles();
1849
+ return {
1850
+ proposal,
1851
+ contract: store.getTaskContract(),
1852
+ capsule: gatherCapsule(store),
1853
+ evidence: store.getMemory('evidence', 'last'),
1854
+ risk: assessBlastRadius(changed),
1855
+ ledger: store.listLedger(40),
1856
+ debate,
1857
+ };
1858
+ }
1859
+ /** A reviewer that is NOT the calling model (`by`) so an "independent challenge" is actually independent:
1860
+ * the configured reviewer if it differs, else the first OTHER installed agent. undefined if none differ. */
1861
+ function independentReviewer(store, by) {
1862
+ const configured = store.getRole('reviewer');
1863
+ if (configured && configured !== by && agentAvailable(configured))
1864
+ return configured;
1865
+ return AGENTS.find((a) => a !== by && agentAvailable(a));
1866
+ }
1867
+ /**
1868
+ * Live structured ingest (M25): ask the reviewer for a JSON verdict on the proposal, extract it
1869
+ * tolerantly, and record it as a Challenge in the debate. The prompt (fixed instruction + the
1870
+ * proposal) goes via stdin — injection-safe. Needs the real reviewer CLI.
1871
+ */
1872
+ function runReviewerChallenge(store, d, reviewer, facts) {
1873
+ const inv = buildInvocation(reviewer, buildReviewerPrompt(facts));
1874
+ console.log(`\nAsking reviewer ${reviewer} for a structured verdict...`);
1875
+ const res = spawnSync(invocationCommand(inv), { input: inv.stdin, encoding: 'utf8', shell: true });
1876
+ const parsed = normalizeReviewerVerdict(extractJson(res.stdout ?? ''));
1877
+ if (!parsed) {
1878
+ console.log(`(reviewer ${reviewer} did not return a parseable verdict; record manually with --block/--accept; raw output:)`);
1879
+ if (res.stdout)
1880
+ process.stdout.write(res.stdout);
1881
+ if (res.stderr)
1882
+ process.stderr.write(res.stderr);
1883
+ return undefined;
1884
+ }
1885
+ d.entries.push({ kind: 'challenge', challenge: challengeFromVerdict(parsed, reviewer) });
1886
+ store.setMemory('debate', 'current', d);
1887
+ store.appendLedger('challenge', reviewer, parsed.verdict);
1888
+ console.log(`(reviewer ${reviewer} responded)\n`);
1889
+ console.log(renderDebate(d, cliUi()));
1890
+ return parsed;
1891
+ }
1892
+ function runLeadChallengeResponse(store, d, lead, facts, verdict) {
1893
+ if (!agentAvailable(lead)) {
1894
+ console.log(`(lead ${lead} is not available for a live response; decide manually.)`);
1895
+ return undefined;
1896
+ }
1897
+ const inv = buildInvocation(lead, buildLeadResponsePrompt(facts, verdict));
1898
+ console.log(`\nAsking lead ${lead} for one bounded response...`);
1899
+ const res = spawnSync(invocationCommand(inv), { input: inv.stdin, encoding: 'utf8', shell: true });
1900
+ const parsed = normalizeLeadModelResponse(extractJson(res.stdout ?? ''));
1901
+ if (!parsed) {
1902
+ console.log(`(lead ${lead} did not return a parseable response; decide manually; raw output:)`);
1903
+ if (res.stdout)
1904
+ process.stdout.write(res.stdout);
1905
+ if (res.stderr)
1906
+ process.stderr.write(res.stderr);
1907
+ return undefined;
1908
+ }
1909
+ d.entries.push({ kind: 'response', response: responseFromLeadModel(parsed, lead) });
1910
+ store.setMemory('debate', 'current', d);
1911
+ store.appendLedger('challenge-response', lead, parsed.text.slice(0, 80));
1912
+ console.log(`(lead ${lead} responded)\n`);
1913
+ console.log(renderDebate(d, cliUi()));
1914
+ return parsed;
1915
+ }
1916
+ function cmdDecide(args) {
1917
+ withStore((store) => {
1918
+ const d = store.getMemory('debate', 'current');
1919
+ if (!d)
1920
+ fail('No open debate. Start one with `framein challenge "<proposal>"`.');
1921
+ const verb = args[0];
1922
+ const text = args.slice(1).join(' ').trim();
1923
+ if (verb !== 'accept' && verb !== 'reject')
1924
+ fail('Usage: framein decide accept|reject [text]');
1925
+ d.entries.push({ kind: 'revision', revision: { text, accepted: verb === 'accept', by: store.getRole('lead') } });
1926
+ store.setMemory('debate', 'current', d);
1927
+ store.appendLedger('decide', verb, text.slice(0, 80));
1928
+ console.log(renderDebate(d, cliUi()));
1929
+ });
1930
+ }
1931
+ function cmdTrust(args) {
1932
+ const agent = args.find((a) => !a.startsWith('--'));
1933
+ if (!agent || !isAgent(agent))
1934
+ fail(`Usage: framein trust <agent> [--ttl <dur>] (agent: ${AGENTS.join('|')})`);
1935
+ let ttlSec;
1936
+ const ttlIdx = args.indexOf('--ttl');
1937
+ if (ttlIdx !== -1) {
1938
+ const parsed = args[ttlIdx + 1] ? parseDuration(args[ttlIdx + 1]) : null;
1939
+ if (parsed === null)
1940
+ fail('Usage: framein trust <agent> --ttl <dur> (e.g. 30m, 1h, 90s)');
1941
+ ttlSec = parsed;
1942
+ }
1943
+ const plan = trustPlan(agent, { ttlSec });
1944
+ // preview only — framein does NOT enable bypass for you (F-TRUST: dangerous, explicit, opt-in).
1945
+ console.log(`trust preview for ${agent} (time-box ~${Math.round(plan.ttlSec / 60)}m):`);
1946
+ console.log(` would add: ${plan.flags.join(' ')}`);
1947
+ for (const w of plan.warnings)
1948
+ console.log(` ⚠ ${w}`);
1949
+ console.log(' framein does NOT auto-enable this — pass the flags yourself, scoped + time-boxed, when you launch.');
1950
+ }
1951
+ const HELP_ROWS = [
1952
+ ['start <goal>', 'start a Task Contract (what "done" means)'],
1953
+ ['task show|amend', 'show / amend the Task Contract'],
1954
+ ['verify', 'run build/test + check validation vs the contract'],
1955
+ ['ship', 'enforced Validation Gate (commit/deploy readiness)'],
1956
+ ['risk', 'Blast Radius Guard: sensitive-file risk + gates'],
1957
+ ['route explain [role]', 'repo-local routing: which agent + why · also: stats'],
1958
+ ['recipe list|show|compile', 'vendor-neutral task protocols, compiled per CLI'],
1959
+ ['debt | explain', 'debt delta of this change · ownership brief'],
1960
+ ['rescue', 'detect a repair loop + propose options (no auto-action)'],
1961
+ ['checkpoint [label]', 'mark the current commit green · also: rewind [--force]'],
1962
+ ['pause | resume', 'save / restore a Task Capsule (handoff-free continuity)'],
1963
+ ['challenge | decide', 'reviewer verdict + one lead response, then decide'],
1964
+ ['init', 'initialize store + project native files'],
1965
+ ['rules show|set|reset', 'view / set the project rules the agent follows (editable defaults)'],
1966
+ ['status', 'show roles, lock, decision count'],
1967
+ ['role set <role> <agent>', 'assign a role (re-syncs files) · also: role list'],
1968
+ ['adr add|supersede|show|list', 'record/replace/show/list decisions (append-only)'],
1969
+ ['sync [--dry-run]', 're-project native files from the store'],
1970
+ ['unlock [scope]', 'release a stale write lock (default: global)'],
1971
+ ['export [path] | import [path]', 'write / rebuild the git-canonical snapshot (JSON)'],
1972
+ ['mcp [patch|register|serve]', 'detected servers / patches / registration / thin server'],
1973
+ ['skills', 'list framein + detected (reused) skills'],
1974
+ ['ask <role> [prompt]', 'preview/record/run a headless delegation [--show|--run]'],
1975
+ ['audit', 'report thrash/anomaly signals from the ledger'],
1976
+ ['ledger add <kind> [t]', 'append a work-event (edit|test-fail|turn|commit…)'],
1977
+ ['trust <agent> [--ttl d]', 'preview per-agent permission-bypass flags (no auto-enable)'],
1978
+ ['lobby', 'optional interactive switchboard — also bare `framein` (zero-dep; native TUI on /go)'],
1979
+ ['setup | doctor', 'detect agent CLIs + recommend/verify wrapper install'],
1980
+ ['integrations <sub> [--write]', 'install/remove logic-less /fr:* wrappers (claude|codex|gemini)'],
1981
+ ['--version | --help', 'version / this help'],
1982
+ ];
1983
+ function printHelp() {
1984
+ console.log('framein (frame) — keep AI coding aligned with intent, validate done, rescue when lost');
1985
+ console.log('CLI: framein · aliases: frame, fr · slash namespace: /fr:* · automation: <verb> --json');
1986
+ console.log('Commands:');
1987
+ const w = Math.max(...HELP_ROWS.map(([c]) => c.length));
1988
+ for (const [c, d] of HELP_ROWS)
1989
+ console.log(` framein ${c.padEnd(w)} ${d}`);
1990
+ }
1991
+ function runCommand(argv) {
1992
+ const [cmd, ...rest] = argv;
1993
+ {
1994
+ // per-command help: `framein <cmd> --help`
1995
+ if (cmd && USAGE[cmd] && rest.includes('--help')) {
1996
+ console.log(USAGE[cmd]);
1997
+ return;
1998
+ }
1999
+ switch (cmd) {
2000
+ case 'init':
2001
+ cmdInit();
2002
+ break;
2003
+ case 'rules':
2004
+ cmdRules(rest);
2005
+ break;
2006
+ case 'status':
2007
+ cmdStatus(rest);
2008
+ break;
2009
+ case 'role':
2010
+ cmdRole(rest);
2011
+ break;
2012
+ case 'adr':
2013
+ cmdAdr(rest);
2014
+ break;
2015
+ case 'sync':
2016
+ cmdSync(rest);
2017
+ break;
2018
+ case 'unlock':
2019
+ cmdUnlock(rest);
2020
+ break;
2021
+ case 'export':
2022
+ cmdExport(rest);
2023
+ break;
2024
+ case 'import':
2025
+ cmdImport(rest);
2026
+ break;
2027
+ case 'mcp':
2028
+ cmdMcp(rest);
2029
+ break;
2030
+ case 'skills':
2031
+ cmdSkills();
2032
+ break;
2033
+ case 'ask':
2034
+ cmdAsk(rest);
2035
+ break;
2036
+ case 'audit':
2037
+ cmdAudit();
2038
+ break;
2039
+ case 'ledger':
2040
+ cmdLedger(rest);
2041
+ break;
2042
+ case 'trust':
2043
+ cmdTrust(rest);
2044
+ break;
2045
+ case 'task':
2046
+ cmdTask(rest);
2047
+ break;
2048
+ case 'start':
2049
+ cmdTask(['start', ...rest]);
2050
+ break; // front-stage verb: start a Task Contract
2051
+ case 'verify':
2052
+ cmdVerify(rest);
2053
+ break;
2054
+ case 'ship':
2055
+ cmdShip(rest);
2056
+ break;
2057
+ case 'risk':
2058
+ cmdRisk(rest);
2059
+ break;
2060
+ case 'route':
2061
+ cmdRoute(rest);
2062
+ break;
2063
+ case 'stats':
2064
+ cmdStats(rest);
2065
+ break;
2066
+ case 'recipe':
2067
+ cmdRecipe(rest);
2068
+ break;
2069
+ case 'debt':
2070
+ cmdDebt(rest);
2071
+ break;
2072
+ case 'explain':
2073
+ cmdExplain(rest);
2074
+ break;
2075
+ case 'rescue':
2076
+ cmdRescue(rest);
2077
+ break;
2078
+ case 'checkpoint':
2079
+ cmdCheckpoint(rest);
2080
+ break;
2081
+ case 'rewind':
2082
+ cmdRewind(rest);
2083
+ break;
2084
+ case 'pause':
2085
+ cmdPause();
2086
+ break;
2087
+ case 'resume':
2088
+ cmdResume();
2089
+ break;
2090
+ case 'capsule':
2091
+ cmdCapsule(rest);
2092
+ break;
2093
+ case 'challenge':
2094
+ cmdChallenge(rest);
2095
+ break;
2096
+ case 'decide':
2097
+ cmdDecide(rest);
2098
+ break;
2099
+ case 'integrations':
2100
+ cmdIntegrations(rest);
2101
+ break;
2102
+ case 'doctor':
2103
+ cmdDoctor();
2104
+ break;
2105
+ case 'setup':
2106
+ cmdSetup();
2107
+ break;
2108
+ case '-v':
2109
+ case '--version':
2110
+ console.log(readVersion());
2111
+ break;
2112
+ case undefined:
2113
+ // Bare `framein` in a terminal drops straight into the lobby; piped/CI invocation stays
2114
+ // automation-safe and prints help (an interactive readline on a non-TTY would hang). The
2115
+ // explicit `framein shell` verb keeps working for both. (stdio:'inherit' in bin.ts preserves
2116
+ // the real TTY through the no-warnings re-exec, so isTTY is accurate here.)
2117
+ if (process.stdin.isTTY) {
2118
+ cmdShell();
2119
+ break;
2120
+ }
2121
+ printHelp();
2122
+ break;
2123
+ case '-h':
2124
+ case '--help':
2125
+ printHelp();
2126
+ break;
2127
+ case 'lobby':
2128
+ case 'shell':
2129
+ cmdShell();
2130
+ break; // `shell` kept as a hidden back-compat alias
2131
+ default:
2132
+ printHelp();
2133
+ throw new CliError(`Unknown command '${cmd}'.`);
2134
+ }
2135
+ }
2136
+ }
2137
+ function main() {
2138
+ try {
2139
+ runCommand(process.argv.slice(2));
2140
+ }
2141
+ catch (e) {
2142
+ if (e instanceof CliError) {
2143
+ if (e.message)
2144
+ console.error(e.message);
2145
+ process.exit(1);
2146
+ }
2147
+ throw e;
2148
+ }
2149
+ }
2150
+ main();