dual-brain 0.2.0 → 0.2.1

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/src/doctor.mjs CHANGED
@@ -20,7 +20,7 @@
20
20
  * verify, verifyAll, getVerificationCache, getStaleAssumptions, formatVerifications
21
21
  */
22
22
 
23
- import { existsSync, readFileSync, writeFileSync, renameSync, appendFileSync } from 'fs';
23
+ import { existsSync, readFileSync, writeFileSync, renameSync, appendFileSync, mkdirSync, readdirSync } from 'fs';
24
24
  import { join } from 'path';
25
25
  import { readdir, readFile } from 'fs/promises';
26
26
  import { exec, execSync } from 'child_process';
@@ -1264,6 +1264,321 @@ export function formatDiscovery(result) {
1264
1264
  return lines.join('\n');
1265
1265
  }
1266
1266
 
1267
+ // ─── EVENT LEDGER ─────────────────────────────────────────────────────────────
1268
+ // Append-only event log at .dualbrain/doctor/events.jsonl
1269
+ // Event types: check_result, gate_failure, contradiction_caught, agent_drift,
1270
+ // manual_fix, incident, check_proposed, check_promoted, check_demoted, check_sentineled
1271
+
1272
+ function doctorDir(cwd) {
1273
+ return join(cwd || process.cwd(), '.dualbrain', 'doctor');
1274
+ }
1275
+
1276
+ function eventsPath(cwd) {
1277
+ return join(doctorDir(cwd), 'events.jsonl');
1278
+ }
1279
+
1280
+ function checksDir(cwd) {
1281
+ return join(doctorDir(cwd), 'checks');
1282
+ }
1283
+
1284
+ function ensureDoctorDir(cwd) {
1285
+ try { mkdirSync(checksDir(cwd), { recursive: true }); } catch { /* ignore */ }
1286
+ }
1287
+
1288
+ /**
1289
+ * recordEvent(event, cwd) — append an event to the doctor event ledger.
1290
+ * Event schema: { ts, type, source, checkId, severity, outcome, evidence, sessionId, release }
1291
+ */
1292
+ export function recordEvent(event, cwd = process.cwd()) {
1293
+ try {
1294
+ ensureDoctorDir(cwd);
1295
+ const entry = {
1296
+ ts: new Date().toISOString(),
1297
+ type: event.type || 'unknown',
1298
+ source: event.source || 'pipeline',
1299
+ checkId: event.checkId || null,
1300
+ severity: event.severity || null,
1301
+ outcome: event.outcome || null,
1302
+ evidence: event.evidence || null,
1303
+ sessionId: event.sessionId || null,
1304
+ release: event.release || null,
1305
+ ...event, // allow extra fields
1306
+ };
1307
+ appendFileSync(eventsPath(cwd), JSON.stringify(entry) + '\n', 'utf8');
1308
+ return entry;
1309
+ } catch { return null; }
1310
+ }
1311
+
1312
+ /**
1313
+ * getRecentEvents(cwd, days) — read events from the last N days.
1314
+ */
1315
+ export function getRecentEvents(cwd = process.cwd(), days = 7) {
1316
+ const p = eventsPath(cwd);
1317
+ if (!existsSync(p)) return [];
1318
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString();
1319
+ try {
1320
+ return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean)
1321
+ .flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } })
1322
+ .filter(e => e.ts >= cutoff);
1323
+ } catch { return []; }
1324
+ }
1325
+
1326
+ /**
1327
+ * getEventsForCheck(checkId, cwd) — filter ledger events by checkId.
1328
+ */
1329
+ export function getEventsForCheck(checkId, cwd = process.cwd()) {
1330
+ const p = eventsPath(cwd);
1331
+ if (!existsSync(p)) return [];
1332
+ try {
1333
+ return readFileSync(p, 'utf8').trim().split('\n').filter(Boolean)
1334
+ .flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } })
1335
+ .filter(e => e.checkId === checkId);
1336
+ } catch { return []; }
1337
+ }
1338
+
1339
+ // ─── CHECK REGISTRY ───────────────────────────────────────────────────────────
1340
+ // Each check spec is stored as a JSON file in .dualbrain/doctor/checks/<id>.json
1341
+
1342
+ const STATIC_CHECK_SEEDS = [
1343
+ { id: 'package-name', kind: 'package-json-field', severity: 'fail' },
1344
+ { id: 'version-scheme', kind: 'package-json-field', severity: 'fail' },
1345
+ { id: 'bin-target', kind: 'export-target', severity: 'fail' },
1346
+ { id: 'exports', kind: 'export-target', severity: 'fail' },
1347
+ { id: 'required-files', kind: 'file-exists', severity: 'fail' },
1348
+ { id: 'branding-check', kind: 'forbidden-string', severity: 'fail' },
1349
+ { id: 'readme-commands', kind: 'readme-contract', severity: 'warn' },
1350
+ { id: 'dead-exports', kind: 'export-target', severity: 'warn' },
1351
+ { id: 'files-array', kind: 'file-exists', severity: 'warn' },
1352
+ { id: 'cli-smoke-test', kind: 'command-exit', severity: 'fail' },
1353
+ { id: 'npm-pack-dry-run', kind: 'command-exit', severity: 'fail' },
1354
+ ];
1355
+
1356
+ function checkSpecPath(checkId, cwd) {
1357
+ return join(checksDir(cwd), `${checkId}.json`);
1358
+ }
1359
+
1360
+ function defaultSpec(seed) {
1361
+ return {
1362
+ id: seed.id,
1363
+ kind: seed.kind,
1364
+ severity: seed.severity,
1365
+ source: seed.source || 'static',
1366
+ status: seed.status || 'active',
1367
+ sentinel: seed.sentinel || false,
1368
+ createdAt: seed.createdAt || new Date().toISOString().slice(0, 10),
1369
+ createdFrom: seed.createdFrom || null,
1370
+ signal: {
1371
+ hits: 0,
1372
+ falsePositives: 0,
1373
+ truePositives: 0,
1374
+ lastSeen: null,
1375
+ lastFailed: null,
1376
+ },
1377
+ };
1378
+ }
1379
+
1380
+ /**
1381
+ * getCheckRegistry(cwd) — load all check specs from the registry directory.
1382
+ * Seeds static checks if they don't exist yet.
1383
+ */
1384
+ export function getCheckRegistry(cwd = process.cwd()) {
1385
+ try {
1386
+ ensureDoctorDir(cwd);
1387
+ // Seed static checks on first call
1388
+ for (const seed of STATIC_CHECK_SEEDS) {
1389
+ const p = checkSpecPath(seed.id, cwd);
1390
+ if (!existsSync(p)) {
1391
+ try {
1392
+ writeFileSync(p, JSON.stringify(defaultSpec(seed), null, 2) + '\n', 'utf8');
1393
+ } catch { /* ignore */ }
1394
+ }
1395
+ }
1396
+ // Load all check specs
1397
+ let entries;
1398
+ try { entries = readdirSync(checksDir(cwd)).filter(f => f.endsWith('.json')); }
1399
+ catch { return []; }
1400
+ return entries.flatMap(fname => {
1401
+ try { return [JSON.parse(readFileSync(join(checksDir(cwd), fname), 'utf8'))]; }
1402
+ catch { return []; }
1403
+ });
1404
+ } catch { return []; }
1405
+ }
1406
+
1407
+ /**
1408
+ * registerCheck(spec, cwd) — add or update a check spec in the registry.
1409
+ */
1410
+ export function registerCheck(spec, cwd = process.cwd()) {
1411
+ if (!spec || !spec.id) throw new Error('registerCheck: spec.id is required');
1412
+ try {
1413
+ ensureDoctorDir(cwd);
1414
+ const p = checkSpecPath(spec.id, cwd);
1415
+ const existing = existsSync(p)
1416
+ ? (() => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return null; } })()
1417
+ : null;
1418
+ const merged = existing ? { ...existing, ...spec } : { ...defaultSpec(spec), ...spec };
1419
+ writeFileSync(p, JSON.stringify(merged, null, 2) + '\n', 'utf8');
1420
+ return merged;
1421
+ } catch (e) { throw new Error(`registerCheck failed: ${e.message}`); }
1422
+ }
1423
+
1424
+ /**
1425
+ * updateCheckStats(checkId, outcome, cwd) — increment signal stats for a check.
1426
+ * outcome: 'pass' | 'fail' | 'warn' | 'false_positive'
1427
+ */
1428
+ export function updateCheckStats(checkId, outcome, cwd = process.cwd()) {
1429
+ try {
1430
+ ensureDoctorDir(cwd);
1431
+ const p = checkSpecPath(checkId, cwd);
1432
+ if (!existsSync(p)) return; // not registered — skip silently
1433
+ let spec; try { spec = JSON.parse(readFileSync(p, 'utf8')); } catch { return; }
1434
+ const signal = spec.signal || { hits: 0, falsePositives: 0, truePositives: 0, lastSeen: null, lastFailed: null };
1435
+ const now = new Date().toISOString();
1436
+ if (outcome === 'fail' || outcome === 'warn') {
1437
+ signal.hits = (signal.hits || 0) + 1;
1438
+ signal.truePositives = (signal.truePositives || 0) + 1;
1439
+ signal.lastSeen = now;
1440
+ signal.lastFailed = now;
1441
+ } else if (outcome === 'false_positive') {
1442
+ signal.hits = (signal.hits || 0) + 1;
1443
+ signal.falsePositives = (signal.falsePositives || 0) + 1;
1444
+ signal.lastSeen = now;
1445
+ } else if (outcome === 'pass') {
1446
+ signal.lastSeen = now;
1447
+ }
1448
+ spec.signal = signal;
1449
+ writeFileSync(p, JSON.stringify(spec, null, 2) + '\n', 'utf8');
1450
+ } catch { /* graceful degradation */ }
1451
+ }
1452
+
1453
+ /**
1454
+ * getCheckHealth(cwd) — summary of all checks with signal stats.
1455
+ */
1456
+ export function getCheckHealth(cwd = process.cwd()) {
1457
+ const registry = getCheckRegistry(cwd);
1458
+ return registry.map(spec => {
1459
+ const sig = spec.signal || {};
1460
+ const hits = sig.hits || 0;
1461
+ const fp = sig.falsePositives || 0;
1462
+ const fpRate = hits >= 5 ? fp / hits : null;
1463
+ return {
1464
+ id: spec.id,
1465
+ kind: spec.kind,
1466
+ status: spec.status,
1467
+ sentinel: spec.sentinel,
1468
+ hits,
1469
+ falsePositives: fp,
1470
+ truePositives: sig.truePositives || 0,
1471
+ fpRate,
1472
+ lastSeen: sig.lastSeen,
1473
+ lastFailed: sig.lastFailed,
1474
+ };
1475
+ });
1476
+ }
1477
+
1478
+ // ─── RECONCILE ────────────────────────────────────────────────────────────────
1479
+ // Core invariant checks that should become sentinel candidates
1480
+ const SENTINEL_INVARIANTS = new Set(['version-scheme', 'package-name', 'bin-target']);
1481
+
1482
+ const VALID_PRIMITIVES = new Set([
1483
+ 'file-exists', 'forbidden-string', 'command-exit', 'command-output',
1484
+ 'package-json-field', 'readme-contract', 'export-target',
1485
+ ]);
1486
+
1487
+ /**
1488
+ * reconcile(cwd) — analyze events and check signal to surface improvement proposals.
1489
+ * Returns { proposals, demotions, sentinels } — never auto-applies changes.
1490
+ */
1491
+ export function reconcile(cwd = process.cwd()) {
1492
+ const proposals = [];
1493
+ const demotions = [];
1494
+ const sentinels = [];
1495
+
1496
+ try {
1497
+ const recentEvents = getRecentEvents(cwd, 7);
1498
+ const registry = getCheckRegistry(cwd);
1499
+ const checkMap = Object.fromEntries(registry.map(c => [c.id, c]));
1500
+
1501
+ // ── 1. Find incidents/gate_failures with no matching check_result failure ──
1502
+ const incidents = recentEvents.filter(e =>
1503
+ e.type === 'incident' || e.type === 'gate_failure'
1504
+ );
1505
+
1506
+ for (const incident of incidents) {
1507
+ const sessionId = incident.sessionId;
1508
+ // Look for any check_result with outcome=fail in the same session
1509
+ const caughtByCheck = recentEvents.some(e =>
1510
+ e.type === 'check_result' &&
1511
+ e.outcome === 'fail' &&
1512
+ sessionId && e.sessionId === sessionId
1513
+ );
1514
+
1515
+ if (!caughtByCheck && incident.evidence) {
1516
+ // Propose a candidate check for this uncaught failure
1517
+ const primitive = _inferPrimitive(incident.evidence);
1518
+ if (primitive) {
1519
+ proposals.push({
1520
+ type: 'check_proposed',
1521
+ candidateId: `auto-${Date.now()}-${proposals.length}`,
1522
+ kind: primitive,
1523
+ severity: 'warn',
1524
+ source: 'reconcile',
1525
+ status: 'quarantine',
1526
+ createdFrom: incident.type,
1527
+ evidence: incident.evidence,
1528
+ rationale: `Uncaught ${incident.type}: ${String(incident.evidence).slice(0, 120)}`,
1529
+ });
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ // ── 2. Checks with high false positive rate → recommend demotion ──────────
1535
+ const health = getCheckHealth(cwd);
1536
+ for (const h of health) {
1537
+ if (h.status !== 'active') continue;
1538
+ if (h.hits >= 5 && h.fpRate !== null && h.fpRate > 0.3) {
1539
+ demotions.push({
1540
+ checkId: h.id,
1541
+ reason: `${Math.round(h.fpRate * 100)}% false positive rate over ${h.hits} runs`,
1542
+ fpRate: h.fpRate,
1543
+ hits: h.hits,
1544
+ });
1545
+ }
1546
+ }
1547
+
1548
+ // ── 3. Checks that never fail in 20+ runs AND guard core invariants ────────
1549
+ for (const h of health) {
1550
+ if (h.status !== 'active') continue;
1551
+ if (h.sentinel) continue; // already sentinel
1552
+ const spec = checkMap[h.id];
1553
+ if (!spec) continue;
1554
+ const guardsInvariant = SENTINEL_INVARIANTS.has(h.id);
1555
+ // Check hasn't fired but is tracking
1556
+ const neverFailed = h.truePositives === 0 && h.hits >= 20;
1557
+ if (guardsInvariant && neverFailed) {
1558
+ sentinels.push({
1559
+ checkId: h.id,
1560
+ reason: `${h.hits} runs without failure — stable invariant guard`,
1561
+ hits: h.hits,
1562
+ });
1563
+ }
1564
+ }
1565
+ } catch { /* graceful degradation — return empty results */ }
1566
+
1567
+ return { proposals, demotions, sentinels };
1568
+ }
1569
+
1570
+ function _inferPrimitive(evidence) {
1571
+ const s = String(evidence).toLowerCase();
1572
+ if (s.includes('file') || s.includes('missing') || s.includes('not found')) return 'file-exists';
1573
+ if (s.includes('string') || s.includes('branding') || s.includes('forbidden')) return 'forbidden-string';
1574
+ if (s.includes('exit') || s.includes('failed') || s.includes('command')) return 'command-exit';
1575
+ if (s.includes('package') || s.includes('version') || s.includes('name')) return 'package-json-field';
1576
+ if (s.includes('readme') || s.includes('doc') || s.includes('contract')) return 'readme-contract';
1577
+ if (s.includes('export')) return 'export-target';
1578
+ if (s.includes('output')) return 'command-output';
1579
+ return null; // can't infer — don't propose
1580
+ }
1581
+
1267
1582
  // ─── Health Baseline Comparison ───────────────────────────────────────────────
1268
1583
  export async function compareHealth(cwd = process.cwd()) {
1269
1584
  const bpath = join(cwd, '.dualbrain', 'health-baseline.json');
package/src/health.mjs CHANGED
@@ -12,6 +12,88 @@
12
12
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
 
15
+ // ─── Auth status (delegates to replit-tools when available) ──────────────────
16
+
17
+ /**
18
+ * Get Claude auth status, preferring replit-tools as the authoritative source.
19
+ *
20
+ * Returns:
21
+ * { ok: boolean, detail: string, source: 'replit-tools' | 'direct' | 'unknown' }
22
+ *
23
+ * @param {string} [cwd]
24
+ */
25
+ export async function getAuthHealthStatus(cwd) {
26
+ const root = cwd ?? process.cwd();
27
+
28
+ // Try replit-tools first (dynamic import — never breaks if absent)
29
+ try {
30
+ const { getAuthStatus } = await import('./replit.mjs');
31
+ const status = getAuthStatus(root);
32
+ if (status.available) {
33
+ const tokenOk = status.tokenStatus === 'valid' || status.tokenStatus === 'unknown';
34
+ const detail = status.tokenStatus === 'valid'
35
+ ? `Auth: OK (via replit-tools${status.expiresAt ? ', expires ' + status.expiresAt : ''})`
36
+ : status.tokenStatus === 'expired'
37
+ ? 'Auth: expired (via replit-tools)'
38
+ : status.tokenStatus === 'expiring'
39
+ ? 'Auth: expiring soon (via replit-tools)'
40
+ : 'Auth: status unknown (via replit-tools)';
41
+ return { ok: tokenOk, detail, source: 'replit-tools' };
42
+ }
43
+ } catch {
44
+ // replit-tools unavailable — fall through to direct check
45
+ }
46
+
47
+ // Fall back: check for .credentials.json directly
48
+ const home = process.env.HOME || '/root';
49
+ const credPaths = [
50
+ join(home, '.claude', '.credentials.json'),
51
+ join(root, '.replit-tools', '.claude-persistent', '.credentials.json'),
52
+ join(root, '.claude-persistent', '.credentials.json'),
53
+ ];
54
+
55
+ for (const p of credPaths) {
56
+ if (!existsSync(p)) continue;
57
+ try {
58
+ const creds = JSON.parse(readFileSync(p, 'utf8'));
59
+ const oauth = creds?.claudeAiOauth;
60
+ if (oauth?.accessToken) {
61
+ const remainingMs = oauth.expiresAt ? oauth.expiresAt - Date.now() : Infinity;
62
+ const remainingHours = Math.floor(remainingMs / 1000 / 60 / 60);
63
+ if (remainingMs <= 0) {
64
+ return { ok: false, detail: 'Auth: token expired (direct check)', source: 'direct' };
65
+ }
66
+ return {
67
+ ok: true,
68
+ detail: `Auth: OK (direct check, ${remainingHours}h remaining)`,
69
+ source: 'direct',
70
+ };
71
+ }
72
+ } catch {
73
+ // continue to next path
74
+ }
75
+ }
76
+
77
+ // .claude.json oauthAccount check
78
+ const claudeJsonPaths = [
79
+ join(root, '.replit-tools', '.claude-persistent', '.claude.json'),
80
+ join(home, '.claude', '.claude.json'),
81
+ ];
82
+ for (const p of claudeJsonPaths) {
83
+ if (!existsSync(p)) continue;
84
+ try {
85
+ const data = JSON.parse(readFileSync(p, 'utf8'));
86
+ if (data?.oauthAccount || data?.apiKey) {
87
+ return { ok: true, detail: 'Auth: OK (direct check via .claude.json)', source: 'direct' };
88
+ }
89
+ } catch {
90
+ // continue
91
+ }
92
+ }
93
+
94
+ return { ok: false, detail: 'Auth: no credentials found (direct check)', source: 'unknown' };
95
+ }
96
+
15
97
  const HEALTH_FILE = '.dualbrain/health.json';
16
98
 
17
99
  // Cooldown ladder in minutes: index = attempts - 1, capped at last entry
package/src/index.mjs CHANGED
@@ -13,7 +13,7 @@ export { dispatch, buildCommand, detectRuntime, compressResult, dispatchDualBrai
13
13
  export { loadPlaybook, listPlaybooks, executePlaybook, createRunArtifact } from './playbook.mjs';
14
14
  export { getHealth, markHot, markDegraded, markHealthy, checkCooldown, getProviderScore, recordDispatch, getSessionStats, resetHealth, remainingCooldownMinutes } from './health.mjs';
15
15
  export { detectRepo, loadRepoCache, getTestCommand, getLintCommand } from './repo.mjs';
16
- export { loadSession, saveSession, updateSession, clearSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, getSessionMeta, autoLabel, enrichSessions, ensurePersistence, syncSessionMirror, buildSessionIndex, searchSessions, getSessionContext } from './session.mjs';
16
+ export { loadSession, saveSession, updateSession, clearSession, formatSessionCard, importReplitSessions, renameSession, pinSession, unpinSession, categorizeSession, getSessionMeta, autoLabel, enrichSessions, ensurePersistence, syncSessionMirror, buildSessionIndex, searchSessions, getSessionContext, extractSessionMeta, getRoutingContext } from './session.mjs';
17
17
  export { decompose, isSimpleTask, taskGraphToWaves } from './decompose.mjs';
18
18
  export { generateBrief, compressPriorResults, listRoles } from './brief.mjs';
19
19
  export { redact, redactFiles, isSecretFile } from './redact.mjs';
@@ -357,8 +357,11 @@ export function detectContradictions(projectBrief, taskBrief, plan = {}) {
357
357
 
358
358
  /**
359
359
  * Format a compact situational awareness summary (max 15 lines) for agent prompts.
360
+ * @param {object} projectBrief
361
+ * @param {object} taskBrief
362
+ * @param {object|null} [sessionContext] Optional: { relatedSessions, riskSignals, priorAttempts, relevantFiles }
360
363
  */
361
- export function formatBrief(projectBrief, taskBrief) {
364
+ export function formatBrief(projectBrief, taskBrief, sessionContext = null) {
362
365
  const lines = [];
363
366
 
364
367
  const dirtyLabel = projectBrief.dirty ? 'dirty' : 'clean';
@@ -419,5 +422,26 @@ export function formatBrief(projectBrief, taskBrief) {
419
422
  }
420
423
  }
421
424
 
425
+ // Session context: include 1-2 lines about prior cross-session work when available
426
+ if (sessionContext) {
427
+ const priorAttempts = Array.isArray(sessionContext.priorAttempts) ? sessionContext.priorAttempts : [];
428
+ const failures = priorAttempts.filter(a => a && (a.failed || a.status === 'failed'));
429
+ const successes = priorAttempts.filter(a => a && !a.failed && a.status !== 'failed');
430
+
431
+ if (failures.length > 0) {
432
+ const lastFail = failures[failures.length - 1];
433
+ const age = lastFail.daysAgo != null ? `${lastFail.daysAgo}d ago` : (lastFail.when ?? 'recently');
434
+ const reason = lastFail.error ?? lastFail.reason ?? '';
435
+ const reasonClip = reason ? ` (${reason.slice(0, 40)})` : '';
436
+ lines.push(`PRIOR: similar task failed ${age}${reasonClip}`);
437
+ } else if (successes.length > 0) {
438
+ const lastSuccess = successes[successes.length - 1];
439
+ const age = lastSuccess.daysAgo != null ? `${lastSuccess.daysAgo}d ago` : (lastSuccess.when ?? 'recently');
440
+ lines.push(`PRIOR: related work completed successfully ${age}`);
441
+ } else if (Array.isArray(sessionContext.relatedSessions) && sessionContext.relatedSessions.length > 0) {
442
+ lines.push(`PRIOR: ${sessionContext.relatedSessions.length} related session(s) found`);
443
+ }
444
+ }
445
+
422
446
  return lines.slice(0, 15).join('\n');
423
447
  }
package/src/pipeline.mjs CHANGED
@@ -76,6 +76,14 @@ export function createPipelineRun(trigger = '', prompt = '') {
76
76
  thinkResult: null, // from think-engine
77
77
  decisionPreflight: null, // from lookupDecision
78
78
 
79
+ // Session history context (populated in Phase 0 from session.mjs)
80
+ sessionContext: null, // { relatedSessions, riskSignals, priorAttempts, relevantFiles }
81
+
82
+ // Replit context (populated in Phase 0 when running inside Replit)
83
+ replitEnvironment: null, // from replit.detectReplitEnvironment()
84
+ replitTools: null, // from replit.inspectReplitTools()
85
+ replitConfig: null, // from replit.getReplitToolsConfig()
86
+
79
87
  completedAt: null,
80
88
  };
81
89
  }
@@ -247,12 +255,12 @@ export function outcomeGate(run) {
247
255
  * @param {string} cwd
248
256
  * @returns {object}
249
257
  */
250
- async function buildContextPack(prompt, files = [], cwd = process.cwd()) {
258
+ async function buildContextPack(prompt, files = [], cwd = process.cwd(), sessionContext = null) {
251
259
  const profile = await _loadProfileSafe(cwd);
252
260
 
253
261
  const priorFailures = _getPriorFailures(prompt, cwd);
254
262
 
255
- const detection = detectTask({ prompt, files, priorFailures });
263
+ const detection = detectTask({ prompt, files, priorFailures, sessionContext });
256
264
 
257
265
  return {
258
266
  prompt,
@@ -261,6 +269,7 @@ async function buildContextPack(prompt, files = [], cwd = process.cwd()) {
261
269
  profile,
262
270
  priorFailures,
263
271
  cwd,
272
+ sessionContext,
264
273
  };
265
274
  }
266
275
 
@@ -389,7 +398,7 @@ export function buildExecutionPlan(contextPack, trigger, options = {}) {
389
398
  effort: depthToEffort[reasoningDepth] ?? detection.effort,
390
399
  };
391
400
 
392
- const decision = decideRoute({ profile, detection: detectionWithDepth, cwd: contextPack.cwd, thinkResult: options.thinkResult });
401
+ const decision = decideRoute({ profile, detection: detectionWithDepth, cwd: contextPack.cwd, thinkResult: options.thinkResult, sessionContext: contextPack.sessionContext ?? null });
393
402
 
394
403
  // Resolve full model ID for display (mirrors dispatch.mjs CLAUDE_MODEL_IDS)
395
404
  const CLAUDE_MODEL_IDS = { opus: 'claude-opus-4-6', sonnet: 'claude-sonnet-4-6', haiku: 'claude-haiku-4-5-20251001' };
@@ -657,11 +666,19 @@ export async function runPipeline(trigger, prompt, options = {}) {
657
666
  try {
658
667
  // ── Phase 0: Situational awareness ───────────────────────────────────────
659
668
 
669
+ // Session history context — load first so Phase 0 modules (intelligence, formatBrief) can use it
670
+ try {
671
+ const session = await import('./session.mjs');
672
+ if (session.getRoutingContext) {
673
+ run.sessionContext = session.getRoutingContext(cwd, prompt);
674
+ }
675
+ } catch {} // session.mjs not available or getRoutingContext not exported — non-blocking
676
+
660
677
  try {
661
678
  const { deriveProjectState, deriveTaskContext, detectContradictions, formatBrief } = await import('./intelligence.mjs');
662
679
  run.projectBrief = await deriveProjectState(options.cwd || process.cwd());
663
680
  run.taskBrief = deriveTaskContext(prompt, options.recentEvents || []);
664
- run.situationBrief = formatBrief(run.projectBrief, run.taskBrief);
681
+ run.situationBrief = formatBrief(run.projectBrief, run.taskBrief, run.sessionContext);
665
682
  } catch (e) {
666
683
  // intelligence module not available — continue without it (degraded)
667
684
  }
@@ -741,6 +758,17 @@ export async function runPipeline(trigger, prompt, options = {}) {
741
758
  // awareness not available
742
759
  }
743
760
 
761
+ // Replit context enrichment — augment run with Replit environment data
762
+ try {
763
+ const replit = await import('./replit.mjs');
764
+ const replitEnv = replit.detectReplitEnvironment(cwd);
765
+ if (replitEnv.isReplit) {
766
+ run.replitEnvironment = replitEnv;
767
+ run.replitTools = replit.inspectReplitTools(cwd);
768
+ run.replitConfig = replit.getReplitToolsConfig(cwd);
769
+ }
770
+ } catch {} // replit.mjs not available — non-blocking
771
+
744
772
  // Knowledge preflight — check if we already know the answer
745
773
  try {
746
774
  const { lookupDecision, triageQuestion } = await import('./think-engine.mjs');
@@ -806,8 +834,8 @@ export async function runPipeline(trigger, prompt, options = {}) {
806
834
 
807
835
  const effectivePrompt = run.enrichedPrompt || prompt;
808
836
 
809
- // Build context pack
810
- run.context = await buildContextPack(effectivePrompt, files, cwd);
837
+ // Build context pack (pass sessionContext so detect can use cross-session signals)
838
+ run.context = await buildContextPack(effectivePrompt, files, cwd, run.sessionContext);
811
839
 
812
840
  // Query failure history (must happen before context gate)
813
841
  try {
@@ -830,6 +858,10 @@ export async function runPipeline(trigger, prompt, options = {}) {
830
858
  // Gate 1: Context gate
831
859
  if (!runGate(run, 'context', contextGate)) {
832
860
  run.completedAt = Date.now();
861
+ try {
862
+ const { recordEvent } = await import('./doctor.mjs');
863
+ recordEvent({ type: 'gate_failure', checkId: 'context-gate', severity: 'fail', outcome: 'blocked', evidence: run.gates.context.reason, sessionId: run.id }, cwd);
864
+ } catch { /* non-blocking */ }
833
865
  return { success: false, gateFailure: 'context', reason: run.gates.context.reason, run };
834
866
  }
835
867
 
@@ -878,6 +910,10 @@ export async function runPipeline(trigger, prompt, options = {}) {
878
910
  const blockers = run.contradictions.filter(c => c.severity === 'block');
879
911
  if (blockers.length > 0) {
880
912
  run.completedAt = Date.now();
913
+ try {
914
+ const { recordEvent } = await import('./doctor.mjs');
915
+ recordEvent({ type: 'contradiction_caught', severity: 'fail', outcome: 'blocked', evidence: blockers.map(b => b.message).join('; ').slice(0, 200), sessionId: run.id }, cwd);
916
+ } catch { /* non-blocking */ }
881
917
  return {
882
918
  success: false,
883
919
  gateFailure: 'contradiction',
@@ -894,12 +930,20 @@ export async function runPipeline(trigger, prompt, options = {}) {
894
930
  // Gate 2: Planning gate
895
931
  if (!runGate(run, 'planning', planningGate)) {
896
932
  run.completedAt = Date.now();
933
+ try {
934
+ const { recordEvent } = await import('./doctor.mjs');
935
+ recordEvent({ type: 'gate_failure', checkId: 'planning-gate', severity: 'fail', outcome: 'blocked', evidence: run.gates.planning.reason, sessionId: run.id }, cwd);
936
+ } catch { /* non-blocking */ }
897
937
  return { success: false, gateFailure: 'planning', reason: run.gates.planning.reason, run };
898
938
  }
899
939
 
900
940
  // Gate 3: Principle gate
901
941
  if (!runGate(run, 'principle', principleGate)) {
902
942
  run.completedAt = Date.now();
943
+ try {
944
+ const { recordEvent } = await import('./doctor.mjs');
945
+ recordEvent({ type: 'gate_failure', checkId: 'principle-gate', severity: 'fail', outcome: 'blocked', evidence: run.gates.principle.reason, sessionId: run.id }, cwd);
946
+ } catch { /* non-blocking */ }
903
947
  return { success: false, gateFailure: 'principle', reason: run.gates.principle.reason, run };
904
948
  }
905
949
 
@@ -925,6 +969,10 @@ export async function runPipeline(trigger, prompt, options = {}) {
925
969
  // Gate 4: Execution gate (cleared to work?)
926
970
  if (!runGate(run, 'execution', executionGate)) {
927
971
  run.completedAt = Date.now();
972
+ try {
973
+ const { recordEvent } = await import('./doctor.mjs');
974
+ recordEvent({ type: 'gate_failure', checkId: 'execution-gate', severity: 'fail', outcome: 'blocked', evidence: run.gates.execution.reason, sessionId: run.id }, cwd);
975
+ } catch { /* non-blocking */ }
928
976
  return { success: false, gateFailure: 'execution', reason: run.gates.execution.reason, run };
929
977
  }
930
978
 
@@ -1038,6 +1086,22 @@ export async function runPipeline(trigger, prompt, options = {}) {
1038
1086
  // living-docs not available — non-blocking
1039
1087
  }
1040
1088
 
1089
+ // Doctor: record execution outcome event (fail-silent)
1090
+ try {
1091
+ const { recordEvent } = await import('./doctor.mjs');
1092
+ const successFlag = run.result && !run.result.error && run.verification?.ok;
1093
+ recordEvent({
1094
+ type: successFlag ? 'execution_success' : 'gate_failure',
1095
+ checkId: 'execution',
1096
+ severity: successFlag ? 'pass' : 'fail',
1097
+ outcome: successFlag ? 'pass' : 'fail',
1098
+ evidence: successFlag
1099
+ ? `Completed ${trigger}: ${prompt.slice(0, 100)}`
1100
+ : (run.result?.error || 'Execution failed'),
1101
+ sessionId: run.id,
1102
+ }, cwd);
1103
+ } catch { /* non-blocking */ }
1104
+
1041
1105
  // Doctor: record learning from this execution outcome (fail-silent)
1042
1106
  try {
1043
1107
  const { recordLearning } = await import('./doctor.mjs');
package/src/profile.mjs CHANGED
@@ -602,6 +602,34 @@ async function autoSetup(cwd) {
602
602
  * @param {string} [cwd]
603
603
  */
604
604
  async function autoRefreshToken(cwd) {
605
+ // Delegate to replit-tools auth refresh script when available,
606
+ // to avoid competing token refreshes from two different code paths.
607
+ try {
608
+ const { getAuthStatus, inspectReplitTools } = await import('./replit.mjs');
609
+ const tools = inspectReplitTools(cwd || process.cwd());
610
+ if (tools.authRefresh?.available) {
611
+ const status = getAuthStatus(cwd || process.cwd());
612
+ if (status.available) {
613
+ // replit-tools owns the refresh cycle — report current status and exit
614
+ const hoursRemaining = status.expiresAt
615
+ ? Math.max(0, Math.floor((Date.parse(status.expiresAt) - Date.now()) / 3_600_000))
616
+ : null;
617
+ if (status.tokenStatus === 'valid') {
618
+ return { status: 'valid', hoursRemaining: hoursRemaining ?? 999, delegatedTo: 'replit-tools' };
619
+ }
620
+ if (status.tokenStatus === 'expired') {
621
+ // replit-tools will handle the actual refresh on its own schedule;
622
+ // we note the state but do not attempt our own refresh.
623
+ return { status: 'expiring_no_refresh', hoursRemaining: 0, delegatedTo: 'replit-tools' };
624
+ }
625
+ // expiring or unknown — note delegation and skip our own refresh attempt
626
+ return { status: 'valid', hoursRemaining: hoursRemaining ?? 1, delegatedTo: 'replit-tools' };
627
+ }
628
+ }
629
+ } catch {
630
+ // replit.mjs unavailable — fall through to direct refresh
631
+ }
632
+
605
633
  const home = process.env.HOME || '/root';
606
634
  const credPaths = [
607
635
  join(home, '.claude', '.credentials.json'),