codehere 0.6.1 → 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.
Files changed (81) hide show
  1. package/README.md +5 -3
  2. package/dist/application/use-cases/domain-trust.js +110 -0
  3. package/dist/application/use-cases/generate-final-report.js +84 -5
  4. package/dist/application/use-cases/generate-narration.js +20 -3
  5. package/dist/application/use-cases/generate-thread-title.js +4 -0
  6. package/dist/application/use-cases/thread-export.js +155 -0
  7. package/dist/application/use-cases/verify-claim.js +274 -0
  8. package/dist/application/use-cases/write-pass.js +277 -0
  9. package/dist/index.js +218 -19
  10. package/dist/infrastructure/completion/bash-completion.js +2 -2
  11. package/dist/infrastructure/config/config-manager.js +1 -1
  12. package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
  13. package/dist/infrastructure/context/adapters/figma-source.js +163 -0
  14. package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
  15. package/dist/infrastructure/context/adapters/linear-source.js +190 -0
  16. package/dist/infrastructure/context/context-resolver.js +144 -0
  17. package/dist/infrastructure/context/context-source.js +114 -0
  18. package/dist/infrastructure/context/oauth-flow.js +207 -0
  19. package/dist/infrastructure/context/oauth-store.js +119 -0
  20. package/dist/infrastructure/context/registry-bootstrap.js +99 -0
  21. package/dist/infrastructure/context/write-pass-resolver.js +106 -0
  22. package/dist/infrastructure/cost/budget-config.js +1 -1
  23. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  24. package/dist/infrastructure/hooks/claude-sdk-hook.js +79 -59
  25. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  26. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  27. package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
  28. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  29. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  30. package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
  31. package/dist/infrastructure/orchestration/codex-adapter.js +157 -19
  32. package/dist/infrastructure/orchestration/context-handoff.js +4 -3
  33. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  34. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  35. package/dist/infrastructure/orchestration/gemini-adapter.js +15 -17
  36. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  37. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  38. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  39. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  40. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  41. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  42. package/dist/infrastructure/orchestration/opencode-adapter.js +23 -3
  43. package/dist/infrastructure/orchestration/progress-aggregator.js +178 -5
  44. package/dist/infrastructure/orchestration/session-manager.js +142 -29
  45. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  46. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  47. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  48. package/dist/infrastructure/protocols/cloud-sync.js +10 -11
  49. package/dist/infrastructure/protocols/mcp-server-impl.js +1295 -17
  50. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  51. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  52. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  53. package/dist/infrastructure/protocols/verify-page.js +941 -0
  54. package/dist/infrastructure/security/trust-scorer.js +76 -1
  55. package/dist/infrastructure/storage/cross-tool-agents.js +20 -3
  56. package/dist/infrastructure/storage/project-thread-migration.js +14 -9
  57. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  58. package/dist/infrastructure/storage/project-thread-title-backfill.js +7 -3
  59. package/dist/monitoring.js +1 -1
  60. package/dist/presentation/cli/commands/audit-command.js +126 -0
  61. package/dist/presentation/cli/commands/budget-command.js +2 -2
  62. package/dist/presentation/cli/commands/config-command.js +2 -2
  63. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  64. package/dist/presentation/cli/commands/help-command.js +51 -1
  65. package/dist/presentation/cli/commands/init-command.js +5 -4
  66. package/dist/presentation/cli/commands/memory-command.js +29 -15
  67. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  68. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  69. package/dist/presentation/cli/commands/report-command.js +6 -4
  70. package/dist/presentation/cli/commands/setup-command.js +71 -18
  71. package/dist/presentation/cli/commands/status-command.js +0 -4
  72. package/dist/presentation/cli/commands/thread-command.js +288 -0
  73. package/dist/presentation/cli/commands/verify-command.js +233 -0
  74. package/dist/presentation/cli/error-display.js +1 -1
  75. package/dist/presentation/cli/repl.js +52 -2
  76. package/dist/presentation/cli/task-deliverable.js +127 -0
  77. package/dist/testing/cassette.js +125 -0
  78. package/dist/testing/replay-adapter.js +166 -0
  79. package/dist/ui.js +54 -29
  80. package/dist/utils/typo-guard.js +1 -1
  81. 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, readFileSync, unlinkSync, } from 'fs';
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
- function readChainTip(filepath) {
476
- if (!existsSync(filepath))
477
- return null;
478
- let content;
479
- try {
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.
@@ -517,7 +531,9 @@ export function appendAuditLog(entry) {
517
531
  catch {
518
532
  // Lock held by another process — spin-wait ~10ms
519
533
  const deadline = Date.now() + 10;
520
- while (Date.now() < deadline) { /* busy-wait — sync context */ }
534
+ while (Date.now() < deadline) {
535
+ /* busy-wait — sync context */
536
+ }
521
537
  }
522
538
  }
523
539
  return false;
@@ -526,7 +542,9 @@ export function appendAuditLog(entry) {
526
542
  try {
527
543
  unlinkSync(LOCK_FILE);
528
544
  }
529
- catch { /* best effort */ }
545
+ catch {
546
+ /* best effort */
547
+ }
530
548
  };
531
549
  if (!lockAcquired()) {
532
550
  // Fall through unlocked rather than drop the entry. The entry still
@@ -707,6 +725,8 @@ export function appendOverrideAudit(reason, partial = {}) {
707
725
  criticalFindings: partial.criticalFindings ?? 0,
708
726
  overrideReason: reason,
709
727
  };
728
+ if (partial.note && partial.note.trim())
729
+ entry.overrideNote = partial.note.trim().slice(0, 500);
710
730
  appendAuditLog(entry);
711
731
  }
712
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
- // - >50 calls in one session = possible amplification
44
- // - >10 Bash calls = elevated risk
45
- // - any blocked calls in a long session = suspicious
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 (callCount > 50 || (bashCount > 10 && callCount > 20))
56
+ else if (blocked > 0)
52
57
  risk = 'medium';
53
- else if (blocked > 0 || callCount > 25)
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
- // Aider has no subscription path — uses ANTHROPIC_API_KEY / OPENAI_API_KEY
33
- // directly against the billed API. codehere users who run on a Claude Code
34
- // subscription typically have a zero-credit API key and hit this fast.
35
- regex: /credit balance is too low|insufficient.*credit|no credits|you exceeded your current quota/i,
36
- message: 'The API key has no credit — Aider cannot run on a Claude Code subscription.',
37
- recovery: 'Aider needs a billed API key (Anthropic or OpenAI). Either top up your key at ' +
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
- regex: /invalid api key|authentication.*failed|401 unauth|Please provide your.*API key/i,
44
- message: 'Aider auth missing or rejected.',
45
- recovery: 'Set ANTHROPIC_API_KEY or OPENAI_API_KEY in your shell, then retry.',
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
- regex: /rate.?limit(ed)?|429|too many requests/i,
50
- message: 'Aider rate-limited the request.',
51
- recovery: 'Wait a minute and retry, or switch --model via extraArgs.',
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
- child = spawn('aider', ['--message', finalPrompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], {
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
- stdoutBuf += chunk.toString('utf-8');
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
- function parseTestSummary(stdout) {
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
- // vitest / jest: "Tests 47 passed, 1 failed (48)" or "passing" (mocha)
198
- const passed = lastMatch(s, /(\d+)\s+pass(?:ed|ing)/i);
199
- const failed = lastMatch(s, /(\d+)\s+fail(?:ed|ing)/i);
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
- return { files, tests };
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