codehere 0.6.2 → 0.7.0
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/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +28 -4
- package/dist/application/use-cases/generate-narration.js +11 -0
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +211 -16
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
- package/dist/infrastructure/orchestration/codex-adapter.js +146 -13
- package/dist/infrastructure/orchestration/context-handoff.js +3 -2
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
- package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
- package/dist/infrastructure/orchestration/session-manager.js +94 -6
- package/dist/infrastructure/orchestration/session-persistence.js +139 -30
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +1 -6
- package/dist/infrastructure/protocols/mcp-server-impl.js +1280 -12
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
- package/dist/infrastructure/storage/project-thread-migration.js +2 -2
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +32 -1
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +18 -1
- package/package.json +4 -2
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Docs: https://docs.anthropic.com/en/docs/claude-code/hooks
|
|
15
15
|
*/
|
|
16
|
-
import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readSync, statSync,
|
|
16
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, openSync, closeSync, readFileSync, readSync, statSync, unlinkSync, } from 'fs';
|
|
17
17
|
import { join } from 'path';
|
|
18
18
|
import { homedir } from 'os';
|
|
19
19
|
import { createHash } from 'crypto';
|
|
@@ -418,51 +418,6 @@ function canonicalizeForHash(entry) {
|
|
|
418
418
|
export function computeEntryHash(entry) {
|
|
419
419
|
return createHash('sha256').update(canonicalizeForHash(entry)).digest('hex');
|
|
420
420
|
}
|
|
421
|
-
/**
|
|
422
|
-
* Read the last non-empty line from a file without loading the whole file.
|
|
423
|
-
* Reads in 4 KB chunks from the end until a newline is found. Returns null
|
|
424
|
-
* if the file is empty or unreadable.
|
|
425
|
-
*
|
|
426
|
-
* For audit-log size <100 KB this is fast (single readSync). Even at 10 MB
|
|
427
|
-
* the worst case is ~3 reads to find a newline.
|
|
428
|
-
*/
|
|
429
|
-
function readLastLine(filepath) {
|
|
430
|
-
try {
|
|
431
|
-
const stats = statSync(filepath);
|
|
432
|
-
if (stats.size === 0)
|
|
433
|
-
return null;
|
|
434
|
-
const fd = openSync(filepath, 'r');
|
|
435
|
-
try {
|
|
436
|
-
const chunkSize = 4096;
|
|
437
|
-
let position = stats.size;
|
|
438
|
-
let buffer = Buffer.alloc(0);
|
|
439
|
-
while (position > 0) {
|
|
440
|
-
const readSize = Math.min(chunkSize, position);
|
|
441
|
-
position -= readSize;
|
|
442
|
-
const chunk = Buffer.alloc(readSize);
|
|
443
|
-
readSync(fd, chunk, 0, readSize, position);
|
|
444
|
-
buffer = Buffer.concat([chunk, buffer]);
|
|
445
|
-
// Strip trailing newlines so the last "line" is the actual content
|
|
446
|
-
const text = buffer.toString('utf-8').replace(/\n+$/, '');
|
|
447
|
-
const lastNewline = text.lastIndexOf('\n');
|
|
448
|
-
if (lastNewline >= 0) {
|
|
449
|
-
return text.slice(lastNewline + 1);
|
|
450
|
-
}
|
|
451
|
-
if (position === 0) {
|
|
452
|
-
// Whole file fits in our buffer
|
|
453
|
-
return text;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return null;
|
|
457
|
-
}
|
|
458
|
-
finally {
|
|
459
|
-
closeSync(fd);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
catch {
|
|
463
|
-
return null;
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
421
|
/**
|
|
467
422
|
* Read the chain tip (seq + entryHash of the last chained entry) from disk.
|
|
468
423
|
* Walks BACKWARD from EOF looking for the most recent line that carries
|
|
@@ -472,17 +427,11 @@ function readLastLine(filepath) {
|
|
|
472
427
|
* continuous across legacy-entry interleavings (rare but possible when
|
|
473
428
|
* an out-of-band script appends raw JSON to the audit file).
|
|
474
429
|
*/
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
content = readFileSync(filepath, 'utf-8');
|
|
481
|
-
}
|
|
482
|
-
catch {
|
|
483
|
-
return null;
|
|
484
|
-
}
|
|
485
|
-
const lines = content.split('\n');
|
|
430
|
+
/**
|
|
431
|
+
* The actual backward-walk: most recent line carrying chain fields wins.
|
|
432
|
+
* Pure over a set of lines so both the bounded and full readers share it.
|
|
433
|
+
*/
|
|
434
|
+
export function tipFromLines(lines) {
|
|
486
435
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
487
436
|
const raw = lines[i];
|
|
488
437
|
if (!raw.trim())
|
|
@@ -500,6 +449,71 @@ function readChainTip(filepath) {
|
|
|
500
449
|
}
|
|
501
450
|
return null;
|
|
502
451
|
}
|
|
452
|
+
function readChainTipFull(filepath) {
|
|
453
|
+
let content;
|
|
454
|
+
try {
|
|
455
|
+
content = readFileSync(filepath, 'utf-8');
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
return tipFromLines(content.split('\n'));
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Read the chain tip (seq + entryHash of the last chained entry) from disk.
|
|
464
|
+
*
|
|
465
|
+
* Hot path: appendAuditLog calls this on every tool call to compute the next
|
|
466
|
+
* seq/prevHash. Reading the whole file each time is O(filesize), and the audit
|
|
467
|
+
* log grows unbounded (one line per tool call), so a heavy user eventually
|
|
468
|
+
* pays a multi-MB read on every call — enough to blow the <50ms hook budget.
|
|
469
|
+
*
|
|
470
|
+
* So we read only the last `tailBytes` and run the SAME backward-walk on that
|
|
471
|
+
* window. The window starts mid-file, so its first line is probably truncated
|
|
472
|
+
* — we drop it (dropping the OLDEST line in the window never changes the most
|
|
473
|
+
* recent chain entry, which is what we return). If the window holds no chain
|
|
474
|
+
* entry (e.g. a long run of legacy entries, or the only entry was the dropped
|
|
475
|
+
* partial line), we fall back to a full read. The bounded read is therefore a
|
|
476
|
+
* pure optimization: it can never return a different tip than the full read,
|
|
477
|
+
* only reach it faster. Proven by the property test in
|
|
478
|
+
* test/unit/chain-tip-bounded.test.ts.
|
|
479
|
+
*/
|
|
480
|
+
export function readChainTip(filepath, tailBytes = 65536) {
|
|
481
|
+
if (!existsSync(filepath))
|
|
482
|
+
return null;
|
|
483
|
+
let size;
|
|
484
|
+
try {
|
|
485
|
+
size = statSync(filepath).size;
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
if (size === 0)
|
|
491
|
+
return null;
|
|
492
|
+
if (size <= tailBytes)
|
|
493
|
+
return readChainTipFull(filepath);
|
|
494
|
+
// Bounded tail read: last `tailBytes` ending at EOF (so the final line is
|
|
495
|
+
// never truncated; only the first line in the window may be).
|
|
496
|
+
let fd;
|
|
497
|
+
try {
|
|
498
|
+
fd = openSync(filepath, 'r');
|
|
499
|
+
const buf = Buffer.alloc(tailBytes);
|
|
500
|
+
const bytesRead = readSync(fd, buf, 0, tailBytes, size - tailBytes);
|
|
501
|
+
const lines = buf.toString('utf-8', 0, bytesRead).split('\n');
|
|
502
|
+
lines.shift(); // drop the possibly-truncated first line
|
|
503
|
+
const tip = tipFromLines(lines);
|
|
504
|
+
if (tip)
|
|
505
|
+
return tip;
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
// fall through to the full read
|
|
509
|
+
}
|
|
510
|
+
finally {
|
|
511
|
+
if (fd !== undefined)
|
|
512
|
+
closeSync(fd);
|
|
513
|
+
}
|
|
514
|
+
// Pathological: no chain entry in the tail window. Pay the full read.
|
|
515
|
+
return readChainTipFull(filepath);
|
|
516
|
+
}
|
|
503
517
|
export function appendAuditLog(entry) {
|
|
504
518
|
// Cross-process file locking. Multiple Claude Code sessions (e.g., two
|
|
505
519
|
// terminals, or IDE + terminal) can trigger PreToolUse concurrently.
|
|
@@ -711,6 +725,8 @@ export function appendOverrideAudit(reason, partial = {}) {
|
|
|
711
725
|
criticalFindings: partial.criticalFindings ?? 0,
|
|
712
726
|
overrideReason: reason,
|
|
713
727
|
};
|
|
728
|
+
if (partial.note && partial.note.trim())
|
|
729
|
+
entry.overrideNote = partial.note.trim().slice(0, 500);
|
|
714
730
|
appendAuditLog(entry);
|
|
715
731
|
}
|
|
716
732
|
// ── Main hook handler ────────────────────────────────────────────────────────
|
|
@@ -39,18 +39,23 @@ export function analyzeSessions(entries) {
|
|
|
39
39
|
if (e.criticalFindings > 0)
|
|
40
40
|
critical++;
|
|
41
41
|
}
|
|
42
|
-
// Risk heuristics
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
42
|
+
// Risk heuristics. The signal we care about is amplification — a runaway
|
|
43
|
+
// or injected session doing things the user didn't watch. Volume ALONE is
|
|
44
|
+
// weak signal: a normal attended dev session easily runs 40+ calls with
|
|
45
|
+
// 20+ Bash (git, build, test, curl) and is perfectly clean. Crying "medium"
|
|
46
|
+
// at that is the false alarm UX_OBSERVATIONS.md P1 warns against, and it
|
|
47
|
+
// erodes trust in the real alerts. So:
|
|
48
|
+
// - any blocked call or critical finding is genuine security signal
|
|
49
|
+
// - pure volume only flags at a high bar (the code's own ">50" threshold)
|
|
50
|
+
// - Bash-heavy only flags when ALSO high-volume (raised 10→20, +calls>40)
|
|
46
51
|
const bashCount = toolMix['Bash'] ?? 0;
|
|
47
52
|
const callCount = sorted.length;
|
|
48
53
|
let risk = 'low';
|
|
49
|
-
if (blocked > 0 && callCount > 20)
|
|
54
|
+
if ((blocked > 0 && callCount > 20) || critical > 0)
|
|
50
55
|
risk = 'high';
|
|
51
|
-
else if (
|
|
56
|
+
else if (blocked > 0)
|
|
52
57
|
risk = 'medium';
|
|
53
|
-
else if (
|
|
58
|
+
else if (callCount > 50 || (bashCount > 20 && callCount > 40))
|
|
54
59
|
risk = 'medium';
|
|
55
60
|
summaries.push({
|
|
56
61
|
sessionId,
|
|
@@ -74,7 +79,7 @@ export function detectAmplificationAlerts(sessions) {
|
|
|
74
79
|
for (const session of sessions) {
|
|
75
80
|
if (session.amplificationRisk === 'low')
|
|
76
81
|
continue;
|
|
77
|
-
let message
|
|
82
|
+
let message;
|
|
78
83
|
const risk = session.amplificationRisk;
|
|
79
84
|
// rc.17 framing: don't accuse "injection" by default — that wording scares
|
|
80
85
|
// users on perfectly normal long attended sessions. Reserve the strong
|
|
@@ -107,6 +112,58 @@ export function detectAmplificationAlerts(sessions) {
|
|
|
107
112
|
}
|
|
108
113
|
return alerts.slice(0, 5); // top 5 alerts
|
|
109
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Real coding tools — the ones a genuine host terminal session uses. Synthetic
|
|
117
|
+
* audit-event types (WritePass.intent, pre_prompt, etc.) and seed fixtures only
|
|
118
|
+
* carry these internal names, so requiring a real tool filters them out.
|
|
119
|
+
*/
|
|
120
|
+
export const REAL_HOST_TOOLS = new Set([
|
|
121
|
+
'Bash',
|
|
122
|
+
'Edit',
|
|
123
|
+
'Write',
|
|
124
|
+
'MultiEdit',
|
|
125
|
+
'Read',
|
|
126
|
+
'Glob',
|
|
127
|
+
'Grep',
|
|
128
|
+
'Task',
|
|
129
|
+
'NotebookEdit',
|
|
130
|
+
'WebFetch',
|
|
131
|
+
]);
|
|
132
|
+
/**
|
|
133
|
+
* Is this a genuine host coding session worth surfacing — not a seed fixture or
|
|
134
|
+
* a stale log entry? Requires at least one real coding tool and recent activity.
|
|
135
|
+
* Surfacing stale/synthetic sessions as "running in your terminal" is the
|
|
136
|
+
* screen-lie the no-screen-lies rule bans.
|
|
137
|
+
*/
|
|
138
|
+
export function isRealHostSession(s, nowMs = Date.now(), recencyMs = 48 * 60 * 60 * 1000) {
|
|
139
|
+
const hasRealTool = Object.keys(s.toolMix).some((t) => REAL_HOST_TOOLS.has(t));
|
|
140
|
+
if (!hasRealTool)
|
|
141
|
+
return false;
|
|
142
|
+
const last = new Date(s.lastSeen).getTime();
|
|
143
|
+
if (!Number.isFinite(last))
|
|
144
|
+
return false;
|
|
145
|
+
return last >= nowMs - recencyMs;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Plain-English preview of a tool call for the host-session thread timeline.
|
|
149
|
+
* The hook stores toolInput as a (sometimes truncated) JSON string. Extract the
|
|
150
|
+
* meaningful field — the bash command, the file path — never dump raw JSON at
|
|
151
|
+
* the user. Falls back to a regex when the JSON is cut off mid-string.
|
|
152
|
+
*/
|
|
153
|
+
export function previewFromToolInput(toolInput, max = 140) {
|
|
154
|
+
if (!toolInput)
|
|
155
|
+
return '';
|
|
156
|
+
let out;
|
|
157
|
+
try {
|
|
158
|
+
const inp = JSON.parse(toolInput);
|
|
159
|
+
out = String(inp.command ?? inp.file_path ?? inp.path ?? inp.pattern ?? inp.url ?? '');
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
const m = toolInput.match(/"(?:command|file_path|path|pattern|url)"\s*:\s*"((?:[^"\\]|\\.)*)/);
|
|
163
|
+
out = m ? m[1].replace(/\\"/g, '"').replace(/\\n/g, ' ') : '';
|
|
164
|
+
}
|
|
165
|
+
return out.slice(0, max);
|
|
166
|
+
}
|
|
110
167
|
/**
|
|
111
168
|
* Format session duration for display.
|
|
112
169
|
*/
|
|
@@ -108,11 +108,6 @@ export class ExecutionTracer {
|
|
|
108
108
|
/**
|
|
109
109
|
* List recent runs
|
|
110
110
|
*/
|
|
111
|
-
listRecentRuns(limit = 10) {
|
|
112
|
-
// In a real implementation, we'd index runs by timestamp
|
|
113
|
-
// For now, return empty array (can be enhanced with an index file)
|
|
114
|
-
return [];
|
|
115
|
-
}
|
|
116
111
|
/**
|
|
117
112
|
* Reproduce a run (replay operations in sequence)
|
|
118
113
|
* Enhanced: Actually replays operations with state restoration
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
import { spawn, execFileSync } from 'child_process';
|
|
16
16
|
import { EventEmitter } from 'events';
|
|
17
17
|
import { randomUUID } from 'crypto';
|
|
18
|
+
import { startRecording } from './cassette-recorder.js';
|
|
18
19
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
20
|
+
import { resolveAgentSpawn } from './mocks/resolve-agent-spawn.js';
|
|
19
21
|
import { applyMemoryPrefix } from './memory-injector.js';
|
|
20
22
|
import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
|
|
21
23
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
@@ -26,36 +28,54 @@ const HEADLESS_FLAGS = [
|
|
|
26
28
|
'--no-stream', // stream=false → one chunk per turn, cleaner stdout parsing
|
|
27
29
|
'--no-auto-commits', // don't auto-commit — leave file changes for artifacts-extractor
|
|
28
30
|
];
|
|
31
|
+
// v0.7 HIGH-1 fix — extended ERROR_PATTERNS for the 18+ provider families
|
|
32
|
+
// Aider supports. Pre-fix: 4 regexes shaped only for Anthropic + OpenAI text.
|
|
33
|
+
// Aider routes through LiteLLM which forwards each provider's native error
|
|
34
|
+
// message, so DeepSeek "Insufficient Balance", Ollama "connection refused",
|
|
35
|
+
// Bedrock "UnrecognizedClientException", Gemini "API key not valid", and even
|
|
36
|
+
// Anthropic's actual "invalid x-api-key" all fell through and never fired
|
|
37
|
+
// failover. Per docs/contracts/aider.md §3 + docs/contracts/00-FINDINGS.md HIGH-1.
|
|
29
38
|
const ERROR_PATTERNS = [
|
|
30
39
|
{
|
|
31
40
|
code: 'credit_low',
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
'console.anthropic.com / platform.openai.com, or switch the task to Claude Code / Codex which use ' +
|
|
39
|
-
'subscription auth.',
|
|
41
|
+
// Anthropic + OpenAI canonical + DeepSeek ("Insufficient Balance").
|
|
42
|
+
regex: /credit balance is too low|insufficient.*credit|no credits|you exceeded your current quota|insufficient.balance/i,
|
|
43
|
+
message: 'The API key has no credit (or balance is too low).',
|
|
44
|
+
recovery: 'Aider needs a billed API key with balance. Top up at your provider (console.anthropic.com / ' +
|
|
45
|
+
'platform.openai.com / platform.deepseek.com), or switch the task to Claude Code / Codex / Gemini ' +
|
|
46
|
+
'which use subscription or free-tier auth.',
|
|
40
47
|
},
|
|
41
48
|
{
|
|
42
49
|
code: 'auth_invalid',
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
50
|
+
// Anthropic + OpenAI + Anthropic's actual "invalid x-api-key" + Gemini's
|
|
51
|
+
// "API key not valid" + Bedrock's "UnrecognizedClientException" + generic.
|
|
52
|
+
regex: /invalid api key|invalid x-api-key|api key not valid|authentication.*failed|401 unauth|please provide your.*api key|unrecognizedclientexception|the security token included in the request is invalid/i,
|
|
53
|
+
message: 'Aider auth was rejected or missing.',
|
|
54
|
+
recovery: 'Check the API key for your model provider (ANTHROPIC_API_KEY, OPENAI_API_KEY, DEEPSEEK_API_KEY, ' +
|
|
55
|
+
'GEMINI_API_KEY, AWS_ACCESS_KEY_ID for Bedrock, etc.). Re-export and retry.',
|
|
46
56
|
},
|
|
47
57
|
{
|
|
48
58
|
code: 'rate_limit',
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
59
|
+
// Generic 429 + named providers (Gemini RESOURCE_EXHAUSTED, Bedrock ThrottlingException).
|
|
60
|
+
regex: /rate.?limit(ed)?|429|too many requests|resource.exhausted|throttlingexception/i,
|
|
61
|
+
message: 'Aider was rate-limited by the provider.',
|
|
62
|
+
recovery: 'Wait a minute and retry, or switch --model via extraArgs to a different provider.',
|
|
52
63
|
},
|
|
53
64
|
{
|
|
54
65
|
code: 'model_unavailable',
|
|
55
|
-
regex: /model.*(not found|not available|unavailable|does not exist)/i,
|
|
66
|
+
regex: /model.*(not found|not available|unavailable|does not exist)|invalidmodelid/i,
|
|
56
67
|
message: 'Aider could not find the model.',
|
|
57
68
|
recovery: 'Run `aider --list-models` and pass `--model <name>` via extraArgs.',
|
|
58
69
|
},
|
|
70
|
+
{
|
|
71
|
+
code: 'network_failure',
|
|
72
|
+
// Ollama (local), self-hosted, generic socket errors. Local providers
|
|
73
|
+
// typically fail at the transport layer with these strings.
|
|
74
|
+
regex: /connection refused|econnrefused|enotfound|fetch failed|getaddrinfo|network is unreachable/i,
|
|
75
|
+
message: "Aider couldn't reach the model provider.",
|
|
76
|
+
recovery: 'Network or provider endpoint issue. For local Ollama: confirm `ollama serve` is running. ' +
|
|
77
|
+
'For cloud providers: check internet + provider status page.',
|
|
78
|
+
},
|
|
59
79
|
];
|
|
60
80
|
function scanForKnownError(text) {
|
|
61
81
|
for (const p of ERROR_PATTERNS) {
|
|
@@ -175,7 +195,8 @@ export function spawnAider(prompt, options = {}) {
|
|
|
175
195
|
const spawnEnv = { ...process.env, ...(options.env ?? {}) };
|
|
176
196
|
let child;
|
|
177
197
|
try {
|
|
178
|
-
|
|
198
|
+
const sp = resolveAgentSpawn('aider', 'aider', ['--message', finalPrompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], finalPrompt);
|
|
199
|
+
child = spawn(sp.command, sp.args, {
|
|
179
200
|
cwd,
|
|
180
201
|
env: spawnEnv,
|
|
181
202
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -210,9 +231,14 @@ export function spawnAider(prompt, options = {}) {
|
|
|
210
231
|
},
|
|
211
232
|
};
|
|
212
233
|
}
|
|
234
|
+
// Organic cassette recording (S2b · capture-first plan) · no-op unless
|
|
235
|
+
// CODEHERE_RECORD_CASSETTES=1, so zero overhead by default.
|
|
236
|
+
const recorder = startRecording({ adapter: 'aider', sessionId, prompt: finalPrompt, cwd });
|
|
213
237
|
let stdoutBuf = '';
|
|
214
238
|
child.stdout?.on('data', (chunk) => {
|
|
215
|
-
|
|
239
|
+
const text = chunk.toString('utf-8');
|
|
240
|
+
recorder.onStdout(text);
|
|
241
|
+
stdoutBuf += text;
|
|
216
242
|
let idx;
|
|
217
243
|
while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
|
|
218
244
|
const line = stdoutBuf.slice(0, idx).trim();
|
|
@@ -224,12 +250,14 @@ export function spawnAider(prompt, options = {}) {
|
|
|
224
250
|
});
|
|
225
251
|
child.stderr?.on('data', (chunk) => {
|
|
226
252
|
const text = chunk.toString('utf-8');
|
|
253
|
+
recorder.onStderr(text);
|
|
227
254
|
for (const line of text.split('\n')) {
|
|
228
255
|
if (line.trim())
|
|
229
256
|
emit({ type: 'stderr', timestamp: Date.now(), data: { line } });
|
|
230
257
|
}
|
|
231
258
|
});
|
|
232
259
|
child.on('exit', (code, signal) => {
|
|
260
|
+
recorder.onExit(code ?? -1, signal);
|
|
233
261
|
// Preserve 'error' if we already flipped to it on codehere:error.
|
|
234
262
|
statusValue =
|
|
235
263
|
killRequested || signal !== null ? 'killed' : errorEmitted ? 'error' : code === 0 ? 'completed' : 'error';
|
|
@@ -190,13 +190,44 @@ function lastMatch(s, re) {
|
|
|
190
190
|
last = m;
|
|
191
191
|
return last;
|
|
192
192
|
}
|
|
193
|
-
|
|
193
|
+
/** Parse a test pass/fail summary out of accumulated stdout. Best-effort and
|
|
194
|
+
* runner-agnostic so "trust the result" holds across whatever the repo uses,
|
|
195
|
+
* not just node runners. Exported for direct unit coverage. */
|
|
196
|
+
export function parseTestSummary(stdout) {
|
|
194
197
|
if (!stdout)
|
|
195
198
|
return undefined;
|
|
196
199
|
const s = stdout.slice(-8000); // only look at the tail
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
+
// Primary — "N passed" / "M failed": vitest, jest, pytest, mocha ("passing"),
|
|
201
|
+
// cargo ("5 passed; 0 failed"). Unchanged so existing matches never shift.
|
|
202
|
+
let passed = lastMatch(s, /(\d+)\s+pass(?:ed|ing)/i);
|
|
203
|
+
let failed = lastMatch(s, /(\d+)\s+fail(?:ed|ing)/i);
|
|
204
|
+
// Fallback A — reversed order "Passed: N" / "Failed: N" (dotnet test, some CI
|
|
205
|
+
// summaries). Only consulted when the primary matched nothing, so the common
|
|
206
|
+
// path is untouched.
|
|
207
|
+
if (!passed && !failed) {
|
|
208
|
+
passed = lastMatch(s, /pass(?:ed)?:\s*(\d+)/i);
|
|
209
|
+
failed = lastMatch(s, /fail(?:ed)?:\s*(\d+)/i);
|
|
210
|
+
}
|
|
211
|
+
// Fallback B — python unittest: "Ran 5 tests in 0.0s" then "OK" or
|
|
212
|
+
// "FAILED (failures=2, errors=1)". There is no "passed" word at all, so the
|
|
213
|
+
// earlier patterns never see it — this was a real drop the dogfood surfaced.
|
|
214
|
+
if (!passed && !failed) {
|
|
215
|
+
const ran = lastMatch(s, /Ran\s+(\d+)\s+tests?\b/i);
|
|
216
|
+
if (ran) {
|
|
217
|
+
const total = parseInt(ran[1], 10);
|
|
218
|
+
const f = lastMatch(s, /failures=(\d+)/i);
|
|
219
|
+
const e = lastMatch(s, /errors=(\d+)/i);
|
|
220
|
+
const failedCount = (f ? parseInt(f[1], 10) : 0) + (e ? parseInt(e[1], 10) : 0);
|
|
221
|
+
const unittest = {
|
|
222
|
+
passed: Math.max(0, total - failedCount),
|
|
223
|
+
failed: failedCount,
|
|
224
|
+
};
|
|
225
|
+
const d = lastMatch(s, /in\s+([\d.]+)s\b/i);
|
|
226
|
+
if (d)
|
|
227
|
+
unittest.durationMs = Math.round(parseFloat(d[1]) * 1000);
|
|
228
|
+
return unittest;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
200
231
|
if (!passed && !failed)
|
|
201
232
|
return undefined;
|
|
202
233
|
const result = {
|
|
@@ -290,6 +321,9 @@ export function extractArtifacts(snap, collectedStdout) {
|
|
|
290
321
|
const agentLikely = filterBySpawnBaseline(snap, sessionScoped);
|
|
291
322
|
const files = annotateTrust(snap, agentLikely);
|
|
292
323
|
const tests = parseTestSummary(collectedStdout);
|
|
293
|
-
|
|
324
|
+
// Non-git cwd: files is empty because we can't diff, not because nothing
|
|
325
|
+
// changed. Flag it so the UI + Final Report say "can't track here" instead
|
|
326
|
+
// of silently implying "no files changed".
|
|
327
|
+
return { files, tests, ...(snap.isGitRepo ? {} : { noGitTracking: true }) };
|
|
294
328
|
}
|
|
295
329
|
//# sourceMappingURL=artifacts-extractor.js.map
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cassette-recorder · organic instrumentation for S2b of the capture-first plan.
|
|
3
|
+
*
|
|
4
|
+
* What it does:
|
|
5
|
+
* When CODEHERE_RECORD_CASSETTES=1 is set, every adapter spawn writes the
|
|
6
|
+
* raw stdout/stderr byte stream + exit to a JSONL cassette under
|
|
7
|
+
* ~/.codehere/cassettes/<adapter>/<scenario>-<ISO>.jsonl. The schema matches
|
|
8
|
+
* the locked L3b format (agent/src/testing/cassette.ts) so the same loader
|
|
9
|
+
* + replay infrastructure works on captures from real production sessions.
|
|
10
|
+
*
|
|
11
|
+
* Why this exists:
|
|
12
|
+
* Per chat 2026-05-23 (path 3 hybrid), we agreed cassettes accumulate from
|
|
13
|
+
* the founder's normal use rather than from dedicated capture sessions
|
|
14
|
+
* requiring fresh API keys. This module is the "accumulates" mechanism.
|
|
15
|
+
*
|
|
16
|
+
* Design choices:
|
|
17
|
+
* - OFF by default. Zero overhead when the env var is unset. The 5 adapters
|
|
18
|
+
* all call `startRecording()`, but it returns a no-op recorder if the
|
|
19
|
+
* env var isn't set, so the per-chunk hooks become free function calls.
|
|
20
|
+
* - Best-effort writes. A failed cassette never breaks the spawn path.
|
|
21
|
+
* - No PII scrubbing here · the user is recording their own sessions on
|
|
22
|
+
* their own machine. If we ever publish cassettes externally, scrubbing
|
|
23
|
+
* belongs in an export tool, not in the recorder.
|
|
24
|
+
* - One cassette per session (no append between sessions). Easier to reason
|
|
25
|
+
* about lifecycle, easier to discard / re-run, easier to ship in a PR.
|
|
26
|
+
*/
|
|
27
|
+
import { appendFileSync, mkdirSync } from 'fs';
|
|
28
|
+
import { homedir } from 'os';
|
|
29
|
+
import { dirname, join } from 'path';
|
|
30
|
+
const ENV_FLAG = 'CODEHERE_RECORD_CASSETTES';
|
|
31
|
+
const NULL_RECORDER = {
|
|
32
|
+
enabled: false,
|
|
33
|
+
path: '',
|
|
34
|
+
onStdout() { },
|
|
35
|
+
onStderr() { },
|
|
36
|
+
onExit() { },
|
|
37
|
+
};
|
|
38
|
+
export function startRecording(opts) {
|
|
39
|
+
if (process.env[ENV_FLAG] !== '1')
|
|
40
|
+
return NULL_RECORDER;
|
|
41
|
+
const outDir = opts.outDir ?? join(homedir(), '.codehere', 'cassettes', opts.adapter);
|
|
42
|
+
const scenario = `organic-${opts.sessionId.slice(0, 8)}`;
|
|
43
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
44
|
+
const path = join(outDir, `${scenario}-${stamp}.jsonl`);
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return NULL_RECORDER;
|
|
50
|
+
}
|
|
51
|
+
const start = Date.now();
|
|
52
|
+
const header = {
|
|
53
|
+
t: 0,
|
|
54
|
+
meta: {
|
|
55
|
+
adapter: opts.adapter,
|
|
56
|
+
scenario,
|
|
57
|
+
captured_at: new Date().toISOString(),
|
|
58
|
+
binary_version: opts.binary_version ?? 'unknown',
|
|
59
|
+
origin: 'real',
|
|
60
|
+
prompt: opts.prompt,
|
|
61
|
+
cwd_hint: hintCwd(opts.cwd),
|
|
62
|
+
schema: 1,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
appendFileSync(path, JSON.stringify(header) + '\n', 'utf-8');
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return NULL_RECORDER;
|
|
70
|
+
}
|
|
71
|
+
let sealed = false;
|
|
72
|
+
return {
|
|
73
|
+
enabled: true,
|
|
74
|
+
path,
|
|
75
|
+
onStdout(chunk) {
|
|
76
|
+
if (sealed)
|
|
77
|
+
return;
|
|
78
|
+
writeEvent(path, { t: Date.now() - start, stream: 'stdout', data: chunk });
|
|
79
|
+
},
|
|
80
|
+
onStderr(chunk) {
|
|
81
|
+
if (sealed)
|
|
82
|
+
return;
|
|
83
|
+
writeEvent(path, { t: Date.now() - start, stream: 'stderr', data: chunk });
|
|
84
|
+
},
|
|
85
|
+
onExit(exit_code, signal) {
|
|
86
|
+
if (sealed)
|
|
87
|
+
return;
|
|
88
|
+
sealed = true;
|
|
89
|
+
writeEvent(path, { t: Date.now() - start, exit_code, signal });
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
94
|
+
/* Internals */
|
|
95
|
+
/* ──────────────────────────────────────────────────────────────────────── */
|
|
96
|
+
function writeEvent(path, payload) {
|
|
97
|
+
try {
|
|
98
|
+
appendFileSync(path, JSON.stringify(payload) + '\n', 'utf-8');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
// best-effort · never break the spawn path
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function hintCwd(cwd) {
|
|
105
|
+
if (!cwd)
|
|
106
|
+
return '.';
|
|
107
|
+
const home = homedir();
|
|
108
|
+
if (cwd === home)
|
|
109
|
+
return '~';
|
|
110
|
+
if (cwd.startsWith(home + '/'))
|
|
111
|
+
return '~/' + cwd.slice(home.length + 1);
|
|
112
|
+
return cwd;
|
|
113
|
+
}
|
|
114
|
+
/** Probe used by tests + the future status command. */
|
|
115
|
+
export function isRecordingEnabled() {
|
|
116
|
+
return process.env[ENV_FLAG] === '1';
|
|
117
|
+
}
|
|
118
|
+
/** Returns the env flag name so callers can document it consistently. */
|
|
119
|
+
export const RECORD_ENV_FLAG = ENV_FLAG;
|
|
120
|
+
//# sourceMappingURL=cassette-recorder.js.map
|