dual-brain 0.2.4 → 0.2.5

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.
@@ -242,11 +242,19 @@ Commands:
242
242
  init First-time setup → flows into interactive REPL
243
243
  auth Show provider login and plan status
244
244
  install Install Claude Code hooks into the current project
245
+ install --global Write hooks into ~/.claude/settings.json (absolute paths,
246
+ fires from any working directory after shell restart)
247
+ uninstall --global Remove dual-brain hooks from ~/.claude/settings.json
245
248
  go "task description" Detect → decide → dispatch (alias for do)
246
249
  --dry-run Show routing decision without executing
247
250
  --files a.mjs,b.mjs Provide file context for risk classification
248
251
  --verbose, -v Print routing trace (intent, risk, health, model selection)
249
252
  think "question" Multi-round architecture decision with dual-brain
253
+ pr Show PR status for current branch
254
+ pr create Create PR from current branch with auto-generated description
255
+ --draft Create as a draft PR
256
+ pr list List open PRs (--closed, --all for other states)
257
+ pr view <N> View PR #N details
250
258
  status Provider health, session stats, available models
251
259
  --verbose, -v Also print profile file path and raw profile object
252
260
  hot <provider> Manually mark all model classes for provider as hot
@@ -373,6 +381,26 @@ async function cmdInit(rl) {
373
381
  // --- Step 2b: Install hooks ---
374
382
  await cmdInstall(cwd);
375
383
 
384
+ // --- Step 2c: Suggest global install if not already done ---
385
+ try {
386
+ const { homedir } = await import('node:os');
387
+ const globalSettingsPath = join(homedir(), '.claude', 'settings.json');
388
+ const DB_MARKER = '# dual-brain-managed';
389
+ let alreadyGlobal = false;
390
+ if (existsSync(globalSettingsPath)) {
391
+ try {
392
+ const gs = JSON.parse(readFileSync(globalSettingsPath, 'utf8'));
393
+ const allHooks = [...(gs.hooks?.PreToolUse || []), ...(gs.hooks?.PostToolUse || [])];
394
+ alreadyGlobal = allHooks.some(e => e.hooks?.some(h => h.command?.includes(DB_MARKER)));
395
+ } catch {}
396
+ }
397
+ if (!alreadyGlobal) {
398
+ console.log('');
399
+ console.log(' Tip: run "dual-brain install --global" to load these hooks from');
400
+ console.log(' any directory — so dual-brain works when Replit restarts a shell.');
401
+ }
402
+ } catch {}
403
+
376
404
  // --- Step 3: Show dashboard ---
377
405
  console.log('');
378
406
  const repo = loadRepoCache(cwd);
@@ -991,6 +1019,139 @@ async function cmdInstall(cwd) {
991
1019
  }
992
1020
  }
993
1021
 
1022
+ async function installGlobal() {
1023
+ const { homedir } = await import('node:os');
1024
+ const globalClaudeDir = join(homedir(), '.claude');
1025
+ const globalSettingsPath = join(globalClaudeDir, 'settings.json');
1026
+
1027
+ // Resolve absolute path to hooks directory via import.meta.url
1028
+ const pkgRoot = join(__dirname, '..');
1029
+ const hooksDir = join(pkgRoot, '.claude', 'hooks');
1030
+
1031
+ // Warn if running from npx (ephemeral path)
1032
+ if (pkgRoot.includes('.npm/_npx') || pkgRoot.includes('npx-')) {
1033
+ console.log(' Warning: Running from npx — paths will break after this session.');
1034
+ console.log(' Install globally first: npm i -g dual-brain');
1035
+ console.log(' Then run: dual-brain install --global');
1036
+ return;
1037
+ }
1038
+
1039
+ // Verify hooks exist at resolved path
1040
+ if (!existsSync(join(hooksDir, 'head-guard.mjs'))) {
1041
+ console.log(' Error: Could not resolve hook files at: ' + hooksDir);
1042
+ return;
1043
+ }
1044
+
1045
+ // Load existing settings (merge, never clobber)
1046
+ let existing = {};
1047
+ if (existsSync(globalSettingsPath)) {
1048
+ try { existing = JSON.parse(readFileSync(globalSettingsPath, 'utf8')); } catch {}
1049
+ }
1050
+
1051
+ // Ensure hooks structure exists
1052
+ if (!existing.hooks) existing.hooks = {};
1053
+ if (!existing.hooks.PreToolUse) existing.hooks.PreToolUse = [];
1054
+ if (!existing.hooks.PostToolUse) existing.hooks.PostToolUse = [];
1055
+
1056
+ // Define dual-brain hooks with ownership marker
1057
+ const DB_MARKER = '# dual-brain-managed';
1058
+ const preToolHooks = [
1059
+ { matcher: 'Edit', hooks: [{ type: 'command', command: `node ${join(hooksDir, 'head-guard.mjs')} ${DB_MARKER}` }] },
1060
+ { matcher: 'Write', hooks: [{ type: 'command', command: `node ${join(hooksDir, 'head-guard.mjs')} ${DB_MARKER}` }] },
1061
+ { matcher: 'NotebookEdit',hooks: [{ type: 'command', command: `node ${join(hooksDir, 'head-guard.mjs')} ${DB_MARKER}` }] },
1062
+ { matcher: 'Bash', hooks: [{ type: 'command', command: `node ${join(hooksDir, 'head-guard.mjs')} ${DB_MARKER}` }] },
1063
+ { matcher: 'Agent', hooks: [{ type: 'command', command: `node ${join(hooksDir, 'enforce-tier.mjs')} ${DB_MARKER}` }] },
1064
+ ];
1065
+ const postToolHooks = [
1066
+ { matcher: '', hooks: [{ type: 'command', command: `node ${join(hooksDir, 'cost-logger.mjs')} ${DB_MARKER}` }] },
1067
+ { matcher: '', hooks: [{ type: 'command', command: `node ${join(hooksDir, 'auto-update-wrapper.mjs')} ${DB_MARKER}` }] },
1068
+ ];
1069
+
1070
+ // Remove any existing dual-brain hooks (idempotent)
1071
+ const isDBHook = (entry) => entry.hooks?.some(h => h.command?.includes(DB_MARKER));
1072
+ existing.hooks.PreToolUse = existing.hooks.PreToolUse.filter(e => !isDBHook(e));
1073
+ existing.hooks.PostToolUse = existing.hooks.PostToolUse.filter(e => !isDBHook(e));
1074
+
1075
+ // Add dual-brain hooks
1076
+ existing.hooks.PreToolUse.push(...preToolHooks);
1077
+ existing.hooks.PostToolUse.push(...postToolHooks);
1078
+
1079
+ // Write merged settings
1080
+ mkdirSync(globalClaudeDir, { recursive: true });
1081
+ writeFileSync(globalSettingsPath, JSON.stringify(existing, null, 2) + '\n');
1082
+
1083
+ // Write minimal global CLAUDE.md (only if none exists, or append section)
1084
+ const globalClaudeMd = join(globalClaudeDir, 'CLAUDE.md');
1085
+ const dbSection = `\n## Dual-Brain Global Hooks\n\nThis machine has dual-brain hooks installed globally.\nProject-local .claude/CLAUDE.md and settings take precedence.\nManaged by: dual-brain install --global\n`;
1086
+
1087
+ if (!existsSync(globalClaudeMd)) {
1088
+ writeFileSync(globalClaudeMd, dbSection);
1089
+ } else {
1090
+ const content = readFileSync(globalClaudeMd, 'utf8');
1091
+ if (!content.includes('Dual-Brain Global Hooks')) {
1092
+ writeFileSync(globalClaudeMd, content + '\n' + dbSection);
1093
+ }
1094
+ }
1095
+
1096
+ console.log(' + dual-brain hooks installed globally');
1097
+ console.log(' hooks dir: ' + hooksDir);
1098
+ console.log(' settings: ' + globalSettingsPath);
1099
+ console.log('');
1100
+ console.log(' All new Claude sessions will load dual-brain hooks.');
1101
+ console.log(' Run "dual-brain uninstall --global" to remove.');
1102
+ }
1103
+
1104
+ async function uninstallGlobal() {
1105
+ const { homedir } = await import('node:os');
1106
+ const globalSettingsPath = join(homedir(), '.claude', 'settings.json');
1107
+
1108
+ if (!existsSync(globalSettingsPath)) {
1109
+ console.log(' No global settings found.');
1110
+ return;
1111
+ }
1112
+
1113
+ let settings = {};
1114
+ try { settings = JSON.parse(readFileSync(globalSettingsPath, 'utf8')); } catch { return; }
1115
+
1116
+ const DB_MARKER = '# dual-brain-managed';
1117
+ const isDBHook = (entry) => entry.hooks?.some(h => h.command?.includes(DB_MARKER));
1118
+
1119
+ let removed = 0;
1120
+ if (settings.hooks?.PreToolUse) {
1121
+ const before = settings.hooks.PreToolUse.length;
1122
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(e => !isDBHook(e));
1123
+ removed += before - settings.hooks.PreToolUse.length;
1124
+ }
1125
+ if (settings.hooks?.PostToolUse) {
1126
+ const before = settings.hooks.PostToolUse.length;
1127
+ settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(e => !isDBHook(e));
1128
+ removed += before - settings.hooks.PostToolUse.length;
1129
+ }
1130
+
1131
+ // Clean up empty arrays/objects
1132
+ if (settings.hooks?.PreToolUse?.length === 0) delete settings.hooks.PreToolUse;
1133
+ if (settings.hooks?.PostToolUse?.length === 0) delete settings.hooks.PostToolUse;
1134
+ if (Object.keys(settings.hooks || {}).length === 0) delete settings.hooks;
1135
+
1136
+ writeFileSync(globalSettingsPath, JSON.stringify(settings, null, 2) + '\n');
1137
+
1138
+ // Remove dual-brain section from global CLAUDE.md
1139
+ const globalClaudeMd = join(homedir(), '.claude', 'CLAUDE.md');
1140
+ if (existsSync(globalClaudeMd)) {
1141
+ let content = readFileSync(globalClaudeMd, 'utf8');
1142
+ const dbSectionRegex = /\n## Dual-Brain Global Hooks\n[\s\S]*?Managed by: dual-brain install --global\n/;
1143
+ content = content.replace(dbSectionRegex, '');
1144
+ if (content.trim()) {
1145
+ writeFileSync(globalClaudeMd, content);
1146
+ } else {
1147
+ unlinkSync(globalClaudeMd);
1148
+ }
1149
+ }
1150
+
1151
+ console.log(` - removed ${removed} dual-brain hook${removed === 1 ? '' : 's'} from global settings`);
1152
+ console.log(' Other settings preserved.');
1153
+ }
1154
+
994
1155
  function cmdRemember(text) {
995
1156
  if (!text) err('Usage: dual-brain remember "preference text"');
996
1157
  const profile = rememberPreference(text, { scope: 'project', cwd: process.cwd() });
@@ -1050,6 +1211,182 @@ function cmdBreakGlass(reason) {
1050
1211
  console.log('└' + '─'.repeat(inner) + '┘');
1051
1212
  }
1052
1213
 
1214
+ // ─── PR command ───────────────────────────────────────────────────────────────
1215
+
1216
+ async function cmdPR(args) {
1217
+ const cwd = process.cwd();
1218
+ const sub = args[0] ?? '';
1219
+
1220
+ // Lazy import — only loaded when 'pr' is invoked
1221
+ let prAgent;
1222
+ try {
1223
+ prAgent = await import('../src/pr-agent.mjs');
1224
+ } catch (e) {
1225
+ console.error('pr-agent module not available:', e.message);
1226
+ process.exit(1);
1227
+ }
1228
+
1229
+ const gh = prAgent.hasGitHub();
1230
+ if (!gh.available) {
1231
+ console.error('gh CLI not found. Install GitHub CLI: https://cli.github.com');
1232
+ process.exit(1);
1233
+ }
1234
+
1235
+ // ── dual-brain pr (show current branch PR status) ──────────────────────────
1236
+ if (!sub || sub === 'status') {
1237
+ if (!gh.authenticated) {
1238
+ console.log('gh CLI is available but not authenticated. Run: gh auth login');
1239
+ return;
1240
+ }
1241
+ const info = prAgent.getBranchInfo(cwd);
1242
+ console.log('\n── PR status ─────────────────────────────────────────────\n');
1243
+ console.log(` Branch: ${info.branch ?? '(unknown)'}`);
1244
+ console.log(` Base: ${info.defaultBranch}`);
1245
+ console.log(` Ahead: ${info.ahead} commit(s)`);
1246
+ console.log(` Behind: ${info.behind} commit(s)`);
1247
+
1248
+ if (info.isDefault) {
1249
+ console.log('\n On default branch — create a feature branch first.');
1250
+ return;
1251
+ }
1252
+
1253
+ // Check for an existing PR on this branch
1254
+ try {
1255
+ const { execSync: _exec } = await import('node:child_process');
1256
+ const json = _exec(`gh pr list --head "${info.branch}" --json number,title,state,url`, {
1257
+ cwd, encoding: 'utf8', timeout: 10000,
1258
+ });
1259
+ const prs = JSON.parse(json);
1260
+ if (prs.length > 0) {
1261
+ const pr = prs[0];
1262
+ console.log(`\n PR #${pr.number}: ${pr.title}`);
1263
+ console.log(` State: ${pr.state}`);
1264
+ console.log(` URL: ${pr.url}`);
1265
+ } else {
1266
+ console.log('\n No PR open for this branch.');
1267
+ console.log(` Create one: dual-brain pr create`);
1268
+ }
1269
+ } catch {
1270
+ console.log('\n (Could not check for existing PR — run: gh pr status)');
1271
+ }
1272
+ console.log('');
1273
+ return;
1274
+ }
1275
+
1276
+ // ── dual-brain pr list ───────────────────────────────────────────────────────
1277
+ if (sub === 'list') {
1278
+ if (!gh.authenticated) {
1279
+ console.log('gh CLI not authenticated. Run: gh auth login');
1280
+ return;
1281
+ }
1282
+ const state = args.includes('--closed') ? 'closed' : args.includes('--all') ? 'all' : 'open';
1283
+ const prs = prAgent.listPRs(cwd, { state, limit: 20 });
1284
+ if (prs.length === 0) {
1285
+ console.log(`No ${state} PRs found.`);
1286
+ return;
1287
+ }
1288
+ console.log(`\n── ${state} PRs ──────────────────────────────────────────────\n`);
1289
+ for (const pr of prs) {
1290
+ const draft = pr.isDraft ? ' [draft]' : '';
1291
+ const date = pr.createdAt ? new Date(pr.createdAt).toLocaleDateString() : '';
1292
+ console.log(` #${pr.number} ${pr.title}${draft}`);
1293
+ console.log(` ${pr.headRefName} by ${pr.author?.login ?? '?'} ${date}`);
1294
+ }
1295
+ console.log('');
1296
+ return;
1297
+ }
1298
+
1299
+ // ── dual-brain pr view <N> ───────────────────────────────────────────────────
1300
+ if (sub === 'view') {
1301
+ const prNum = args[1];
1302
+ if (!prNum || isNaN(Number(prNum))) {
1303
+ console.error('Usage: dual-brain pr view <PR-number>');
1304
+ process.exit(1);
1305
+ }
1306
+ const details = prAgent.getPRDetails(prNum, cwd);
1307
+ if (!details) {
1308
+ console.error(`PR #${prNum} not found or gh CLI error.`);
1309
+ process.exit(1);
1310
+ }
1311
+ console.log(`\n── PR #${prNum}: ${details.title} ─────────────────────────────\n`);
1312
+ console.log(` State: ${details.state}`);
1313
+ console.log(` Branch: ${details.headRefName} → ${details.baseRefName}`);
1314
+ console.log(` Changes: +${details.additions} -${details.deletions} (${details.changedFiles} files)`);
1315
+ if (details.statusCheckRollup?.length) {
1316
+ const passing = details.statusCheckRollup.filter(c => c.conclusion === 'SUCCESS').length;
1317
+ const total = details.statusCheckRollup.length;
1318
+ console.log(` Checks: ${passing}/${total} passing`);
1319
+ }
1320
+ if (details.body) {
1321
+ console.log('\n Body:\n');
1322
+ console.log(details.body.split('\n').map(l => ` ${l}`).join('\n').slice(0, 1500));
1323
+ }
1324
+ console.log('');
1325
+ return;
1326
+ }
1327
+
1328
+ // ── dual-brain pr create ─────────────────────────────────────────────────────
1329
+ if (sub === 'create') {
1330
+ if (!gh.authenticated) {
1331
+ console.log('gh CLI not authenticated. Run: gh auth login');
1332
+ return;
1333
+ }
1334
+ const info = prAgent.getBranchInfo(cwd);
1335
+ if (info.isDefault || !info.branch) {
1336
+ console.error('You are on the default branch. Switch to a feature branch before creating a PR.');
1337
+ process.exit(1);
1338
+ }
1339
+ if (info.ahead === 0) {
1340
+ console.error('No commits ahead of the base branch. Make changes and commit first.');
1341
+ process.exit(1);
1342
+ }
1343
+
1344
+ const diff = prAgent.getDiffSummary(info.defaultBranch, cwd);
1345
+ const draft = args.includes('--draft');
1346
+
1347
+ // Auto-generate a title from the branch name
1348
+ const rawTitle = info.branch.replace(/^db\//, '').replace(/-/g, ' ');
1349
+ const title = rawTitle.charAt(0).toUpperCase() + rawTitle.slice(1);
1350
+
1351
+ // Auto-generate body from diff
1352
+ const body = prAgent.buildPRBody(title, {
1353
+ filesChanged: diff.files,
1354
+ });
1355
+
1356
+ console.log(`\n── Creating PR from ${info.branch} → ${info.defaultBranch} ────────────\n`);
1357
+ console.log(` Title: ${title}`);
1358
+ console.log(` Files: ${diff.fileCount} changed`);
1359
+ if (diff.summary) console.log(` Diff: ${diff.summary}`);
1360
+ if (draft) console.log(' Mode: draft');
1361
+ console.log('');
1362
+
1363
+ const result = prAgent.createPR({
1364
+ title,
1365
+ body,
1366
+ baseBranch: info.defaultBranch,
1367
+ draft,
1368
+ cwd,
1369
+ });
1370
+
1371
+ if (result.success) {
1372
+ console.log(` PR created: ${result.url}`);
1373
+ } else {
1374
+ console.error(` Failed to create PR: ${result.error}`);
1375
+ process.exit(1);
1376
+ }
1377
+ console.log('');
1378
+ return;
1379
+ }
1380
+
1381
+ // Unknown sub-subcommand
1382
+ console.log(`Unknown pr subcommand: "${sub}"`);
1383
+ console.log('Usage:');
1384
+ console.log(' dual-brain pr Show PR status for current branch');
1385
+ console.log(' dual-brain pr create Create PR from current branch');
1386
+ console.log(' dual-brain pr list List open PRs');
1387
+ console.log(' dual-brain pr view <N> View PR details');
1388
+ }
1389
+
1053
1390
  // ─── Screen helpers ───────────────────────────────────────────────────────────
1054
1391
 
1055
1392
  /**
@@ -5640,7 +5977,16 @@ async function main() {
5640
5977
  }
5641
5978
 
5642
5979
  // One-shot commands — run and exit
5643
- if (cmd === 'install') { await cmdInstall(); return; }
5980
+ if (cmd === 'install') {
5981
+ if (args.includes('--global')) { await installGlobal(); return; }
5982
+ await cmdInstall();
5983
+ return;
5984
+ }
5985
+ if (cmd === 'uninstall') {
5986
+ if (args.includes('--global')) { await uninstallGlobal(); return; }
5987
+ console.log('Usage: dual-brain uninstall --global');
5988
+ return;
5989
+ }
5644
5990
  if (cmd === 'auth') {
5645
5991
  await cmdAuth(args.slice(1));
5646
5992
  return;
@@ -5651,6 +5997,7 @@ async function main() {
5651
5997
  if (cmd === 'think') { await cmdThink(args.slice(1)); return; }
5652
5998
  if (cmd === 'review') { await cmdReview(args.slice(1)); return; }
5653
5999
  if (cmd === 'ship') { await cmdShip(); return; }
6000
+ if (cmd === 'pr') { await cmdPR(args.slice(1)); return; }
5654
6001
  if (cmd === 'status') { await cmdStatus(args.slice(1)); return; }
5655
6002
  if (cmd === 'hot') { cmdHot(args[1]); return; }
5656
6003
  if (cmd === 'cool') { cmdCool(args[1]); return; }
@@ -5715,7 +6062,7 @@ fi
5715
6062
  // If cmd is not a recognized subcommand, treat the entire arg list as a task.
5716
6063
  // e.g. `dual-brain fix failing tests` → same as `dual-brain go "fix failing tests"`
5717
6064
  const KNOWN_COMMANDS = new Set([
5718
- 'init', 'install', 'auth', 'go', 'do', 'plan', 'ship', 'think', 'review', 'status', 'hot', 'cool',
6065
+ 'init', 'install', 'uninstall', 'auth', 'go', 'do', 'plan', 'ship', 'think', 'review', 'pr', 'status', 'hot', 'cool',
5719
6066
  'remember', 'forget', 'break-glass', 'specialists', 'search', 'shell-hook', 'watch',
5720
6067
  '--help', '-h', '--version', '-v',
5721
6068
  ...Object.keys(loadSpecialistRegistry()),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dual-brain",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
4
4
  "description": "AI orchestration across Claude + OpenAI subscriptions — smart routing, budget awareness, and dual-brain collaboration",
5
5
  "type": "module",
6
6
  "bin": {
@@ -23,7 +23,11 @@
23
23
  "./calibration": "./src/calibration.mjs",
24
24
  "./models": "./src/models.mjs",
25
25
  "./prompt-intel": "./src/prompt-intel.mjs",
26
- "./replit": "./src/replit.mjs"
26
+ "./replit": "./src/replit.mjs",
27
+ "./continuity": "./src/continuity.mjs",
28
+ "./checkpoint": "./src/checkpoint.mjs",
29
+ "./pr-agent": "./src/pr-agent.mjs",
30
+ "./ci-triage": "./src/ci-triage.mjs"
27
31
  },
28
32
  "keywords": [
29
33
  "claude-code",
@@ -86,6 +90,10 @@
86
90
  "src/prompt-intel.mjs",
87
91
  "src/replit.mjs",
88
92
  "src/fx.mjs",
93
+ "src/continuity.mjs",
94
+ "src/checkpoint.mjs",
95
+ "src/ci-triage.mjs",
96
+ "src/pr-agent.mjs",
89
97
  "bin/*.mjs",
90
98
  "hooks/enforce-tier.mjs",
91
99
  "hooks/cost-logger.mjs",
package/src/awareness.mjs CHANGED
@@ -358,3 +358,39 @@ export function invalidateCache() {
358
358
  _cache = null;
359
359
  _cacheTime = 0;
360
360
  }
361
+
362
+ // ─── Ambiguity Detection ──────────────────────────────────────────────────────
363
+
364
+ const TECHNICAL_TERMS = /\b(fix|bug|error|test|deploy|refactor|import|export|function|class|module|api|endpoint|auth|token|database|query|schema|migration|build|lint|type|interface|component|route|handler|middleware|config|env|secret|key|file|path|directory|repo|branch|commit|merge|pull|push|install|upgrade|package|dependency|version|release|publish|log|trace|debug|stack|exception|undefined|null|async|await|promise|fetch|request|response|status|server|client|socket|cache|session)\b/i;
365
+
366
+ /**
367
+ * Detect whether a prompt is ambiguous and needs clarification before dispatch.
368
+ *
369
+ * A prompt is considered ambiguous when ALL of the following are true:
370
+ * 1. It is very short (under 4 words)
371
+ * 2. No file context is provided
372
+ * 3. It lacks specific technical terms that narrow the intent
373
+ *
374
+ * @param {string} prompt — the user's raw prompt
375
+ * @param {{ files?: string[] }} [context] — optional context (e.g. file paths)
376
+ * @returns {{ isAmbiguous: boolean, reason: string|null }}
377
+ */
378
+ export function detectAmbiguity(prompt, context = {}) {
379
+ if (!prompt || typeof prompt !== 'string') {
380
+ return { isAmbiguous: true, reason: 'missing context: empty prompt' };
381
+ }
382
+
383
+ const words = prompt.trim().split(/\s+/).filter(Boolean);
384
+ const isTooShort = words.length < 4;
385
+ const hasFileContext = Array.isArray(context?.files) && context.files.length > 0;
386
+ const hasTechnicalTerms = TECHNICAL_TERMS.test(prompt);
387
+
388
+ if (isTooShort && !hasFileContext && !hasTechnicalTerms) {
389
+ return {
390
+ isAmbiguous: true,
391
+ reason: `unclear: prompt is vague ("${prompt.trim()}") — missing context about what to change and where`,
392
+ };
393
+ }
394
+
395
+ return { isAmbiguous: false, reason: null };
396
+ }
@@ -0,0 +1,109 @@
1
+ // checkpoint.mjs — Checkpoint wrapper for dual-brain execution safety.
2
+ // Wraps Replit's native checkpoint system with a git-based fallback.
3
+ // Exports: hasCheckpoints, createCheckpoint, listCheckpoints, getLastCheckpoint
4
+
5
+ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ import { execSync } from 'node:child_process';
8
+
9
+ /**
10
+ * Check if checkpoint capability is available.
11
+ * @returns {boolean}
12
+ */
13
+ export function hasCheckpoints() {
14
+ try {
15
+ // Check for Replit checkpoint binary
16
+ if (existsSync('/usr/local/bin/replit-checkpoint')) return true;
17
+ execSync('which replit-checkpoint', { stdio: 'pipe', timeout: 2000 });
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Create a checkpoint before a risky operation.
26
+ * @param {string} label — human-readable label like "before auth refactor"
27
+ * @param {object} [opts]
28
+ * @param {string} [opts.cwd]
29
+ * @returns {{ success: boolean, id: string|null, label: string, timestamp: string }}
30
+ */
31
+ export function createCheckpoint(label, opts = {}) {
32
+ const cwd = opts.cwd || process.cwd();
33
+ const timestamp = new Date().toISOString();
34
+ const id = `cp-${Date.now()}`;
35
+
36
+ // Try Replit checkpoint first
37
+ if (hasCheckpoints()) {
38
+ try {
39
+ execSync('replit-checkpoint create', { cwd, stdio: 'pipe', timeout: 10000 });
40
+ _logCheckpoint({ id, label, timestamp, type: 'replit', status: 'created' }, cwd);
41
+ return { success: true, id, label, timestamp };
42
+ } catch {
43
+ // Fall through to git-based checkpoint
44
+ }
45
+ }
46
+
47
+ // Fallback: git stash + tag
48
+ try {
49
+ // Stash any uncommitted changes
50
+ const status = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 5000 }).trim();
51
+ if (status) {
52
+ execSync(`git stash push -m "dual-brain-checkpoint: ${label}"`, { cwd, stdio: 'pipe', timeout: 10000 });
53
+ execSync('git stash pop', { cwd, stdio: 'pipe', timeout: 10000 });
54
+ }
55
+ // Create a lightweight tag
56
+ const safeLabel = label.replace(/[^a-zA-Z0-9-_]/g, '-').slice(0, 50);
57
+ const tagName = `db-checkpoint/${safeLabel}-${Date.now()}`;
58
+ execSync(`git tag "${tagName}"`, { cwd, stdio: 'pipe', timeout: 5000 });
59
+ _logCheckpoint({ id, label, timestamp, type: 'git-tag', tag: tagName, status: 'created' }, cwd);
60
+ return { success: true, id, label, timestamp };
61
+ } catch {
62
+ _logCheckpoint({ id, label, timestamp, type: 'failed', status: 'failed' }, cwd);
63
+ return { success: false, id: null, label, timestamp };
64
+ }
65
+ }
66
+
67
+ /**
68
+ * List recent checkpoints (most recent first, up to 20).
69
+ * @param {string} [cwd]
70
+ * @returns {object[]}
71
+ */
72
+ export function listCheckpoints(cwd) {
73
+ const logPath = join(cwd || process.cwd(), '.dual-brain', 'checkpoints.jsonl');
74
+ if (!existsSync(logPath)) return [];
75
+ try {
76
+ return readFileSync(logPath, 'utf8')
77
+ .trim()
78
+ .split('\n')
79
+ .filter(Boolean)
80
+ .map(line => JSON.parse(line))
81
+ .reverse()
82
+ .slice(0, 20);
83
+ } catch {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Get the most recent checkpoint.
90
+ * @param {string} [cwd]
91
+ * @returns {object|null}
92
+ */
93
+ export function getLastCheckpoint(cwd) {
94
+ const checkpoints = listCheckpoints(cwd);
95
+ return checkpoints[0] || null;
96
+ }
97
+
98
+ // ─── Internal helpers ─────────────────────────────────────────────────────────
99
+
100
+ function _logCheckpoint(entry, cwd) {
101
+ const dir = join(cwd || process.cwd(), '.dual-brain');
102
+ mkdirSync(dir, { recursive: true });
103
+ const logPath = join(dir, 'checkpoints.jsonl');
104
+ const line = JSON.stringify(entry) + '\n';
105
+ try {
106
+ const existing = existsSync(logPath) ? readFileSync(logPath, 'utf8') : '';
107
+ writeFileSync(logPath, existing + line);
108
+ } catch {}
109
+ }