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/bin/dual-brain.mjs +56 -0
- package/package.json +4 -2
- package/src/awareness.mjs +17 -0
- package/src/decide.mjs +46 -2
- package/src/detect.mjs +119 -4
- package/src/dispatch.mjs +19 -0
- package/src/doctor.mjs +316 -1
- package/src/health.mjs +82 -0
- package/src/index.mjs +1 -1
- package/src/intelligence.mjs +25 -1
- package/src/pipeline.mjs +70 -6
- package/src/profile.mjs +28 -0
- package/src/replit.mjs +1210 -0
- package/src/session.mjs +285 -14
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';
|
package/src/intelligence.mjs
CHANGED
|
@@ -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'),
|