codehere 0.4.1 → 0.5.0-alpha.3

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 (24) hide show
  1. package/README.md +64 -7
  2. package/dist/application/use-cases/generate-final-report.js +144 -0
  3. package/dist/index.js +47 -4
  4. package/dist/infrastructure/hooks/claude-sdk-hook.js +18 -3
  5. package/dist/infrastructure/orchestration/aider-adapter.js +12 -2
  6. package/dist/infrastructure/orchestration/claude-code-adapter.js +70 -1
  7. package/dist/infrastructure/orchestration/codex-adapter.js +6 -1
  8. package/dist/infrastructure/orchestration/conductor-adapter.js +12 -2
  9. package/dist/infrastructure/orchestration/gemini-adapter.js +41 -5
  10. package/dist/infrastructure/orchestration/generic-cli-adapter.js +10 -2
  11. package/dist/infrastructure/orchestration/memory-injector.js +337 -0
  12. package/dist/infrastructure/orchestration/opencode-adapter.js +34 -4
  13. package/dist/infrastructure/orchestration/progress-aggregator.js +119 -0
  14. package/dist/infrastructure/orchestration/readiness.js +2 -5
  15. package/dist/infrastructure/orchestration/session-manager.js +65 -2
  16. package/dist/infrastructure/orchestration/session-persistence.js +13 -1
  17. package/dist/infrastructure/protocols/mcp-server-impl.js +86 -14
  18. package/dist/infrastructure/protocols/ui-page.js +665 -67
  19. package/dist/presentation/cli/commands/init-command.js +345 -24
  20. package/dist/presentation/cli/commands/memory-command.js +1017 -0
  21. package/dist/presentation/cli/commands/protocols-builder.js +254 -0
  22. package/dist/presentation/cli/commands/protocols-command.js +84 -0
  23. package/dist/presentation/cli/commands/repo-map-builder.js +247 -0
  24. package/package.json +4 -3
@@ -31,7 +31,11 @@
31
31
  import { spawn, execFileSync } from 'child_process';
32
32
  import { EventEmitter } from 'events';
33
33
  import { randomUUID } from 'crypto';
34
+ import { existsSync } from 'fs';
35
+ import { homedir } from 'os';
36
+ import { join } from 'path';
34
37
  import { ProgressAggregator } from './progress-aggregator.js';
38
+ import { applyMemoryPrefix } from './memory-injector.js';
35
39
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
36
40
  // `--approval-mode=yolo` auto-accepts every tool action without prompting (the
37
41
  // older `--yolo` flag is deprecated). codehere's safety hook is the external
@@ -91,6 +95,9 @@ export function spawnGemini(prompt, options = {}) {
91
95
  let statusValue = 'running';
92
96
  let killRequested = false;
93
97
  const cwd = options.cwd ?? process.cwd();
98
+ // F3 Task 8 — memory injection (same shape as every other adapter).
99
+ const injected = applyMemoryPrefix(prompt, cwd, options);
100
+ const finalPrompt = injected.prompt;
94
101
  const snapshot = snapshotCwd(cwd);
95
102
  const aggregator = new ProgressAggregator('gemini');
96
103
  let stepsSnapshot = [];
@@ -187,7 +194,7 @@ export function spawnGemini(prompt, options = {}) {
187
194
  };
188
195
  let child;
189
196
  try {
190
- child = spawn('gemini', ['-p', prompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], {
197
+ child = spawn('gemini', ['-p', finalPrompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], {
191
198
  cwd,
192
199
  env: spawnEnv,
193
200
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -301,6 +308,7 @@ export function spawnGemini(prompt, options = {}) {
301
308
  startedAt,
302
309
  prompt,
303
310
  agent: 'gemini',
311
+ memoryInjection: injected.record,
304
312
  get status() {
305
313
  return statusValue;
306
314
  },
@@ -379,10 +387,38 @@ export const GeminiAdapter = {
379
387
  },
380
388
  };
381
389
  }
382
- // Gemini supports GEMINI_API_KEY (free tier at aistudio.google.com), GOOGLE_API_KEY,
383
- // Vertex AI (GOOGLE_GENAI_USE_VERTEXAI=true), GCA (GOOGLE_GENAI_USE_GCA=true), or
384
- // cached Google login. Can't verify without a roundtrip — let runtime catch
385
- // real failures via ERROR_PATTERNS.auth_missing on the result.error event.
390
+ // Gemini supports several auth paths:
391
+ // env: GEMINI_API_KEY (free tier), GOOGLE_API_KEY, GOOGLE_GENAI_USE_VERTEXAI,
392
+ // GOOGLE_GENAI_USE_GCA, GOOGLE_APPLICATION_CREDENTIALS
393
+ // file: ~/.gemini/settings.json (the file gemini's own error msg points at)
394
+ // file: ~/.gemini/oauth_creds.json (cached Google login)
395
+ //
396
+ // Pre-May-1 we returned 'ready' on install alone, deferring to runtime to
397
+ // catch missing-auth via ERROR_PATTERNS.auth_missing. That was dishonest:
398
+ // /api/readiness/agents would say 'ready' → user would spawn → 41s later
399
+ // the task errored with no clear next step. Surfaced May 1 2026 by the
400
+ // multi-agent dogfood test (see SECURITY_AUDIT_2026-05-01.md spirit —
401
+ // honest readiness signals are a UX security property).
402
+ const hasEnvAuth = !!process.env.GEMINI_API_KEY ||
403
+ !!process.env.GOOGLE_API_KEY ||
404
+ !!process.env.GOOGLE_GENAI_USE_VERTEXAI ||
405
+ !!process.env.GOOGLE_GENAI_USE_GCA ||
406
+ !!process.env.GOOGLE_APPLICATION_CREDENTIALS;
407
+ const hasFileAuth = existsSync(join(homedir(), '.gemini', 'settings.json')) ||
408
+ existsSync(join(homedir(), '.gemini', 'oauth_creds.json'));
409
+ if (!hasEnvAuth && !hasFileAuth) {
410
+ return {
411
+ id: this.id,
412
+ label: this.label,
413
+ state: 'needs_auth',
414
+ installed: true,
415
+ authHint: {
416
+ command: 'export GEMINI_API_KEY=... # free tier: https://aistudio.google.com/apikey',
417
+ url: 'https://google-gemini.github.io/gemini-cli/docs/get-started/authentication.html',
418
+ detail: 'Gemini found on PATH but no auth detected (no GEMINI_API_KEY env var, no ~/.gemini/settings.json, no ~/.gemini/oauth_creds.json). Free tier available at aistudio.google.com/apikey.',
419
+ },
420
+ };
421
+ }
386
422
  return { id: this.id, label: this.label, state: 'ready', installed: true };
387
423
  },
388
424
  };
@@ -37,6 +37,7 @@ import { join } from 'path';
37
37
  import { randomUUID } from 'crypto';
38
38
  import { ProgressAggregator } from './progress-aggregator.js';
39
39
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
40
+ import { applyMemoryPrefix } from './memory-injector.js';
40
41
  const CONFIG_PATH = join(homedir(), '.codehere', 'agents.yaml');
41
42
  const CONFIG_PATH_JSON = join(homedir(), '.codehere', 'agents.json');
42
43
  /** Minimal YAML parser for the narrow shape above. Intentionally NOT a full YAML
@@ -134,6 +135,12 @@ function spawnGeneric(cfg, prompt, options = {}) {
134
135
  let statusValue = 'running';
135
136
  let killRequested = false;
136
137
  const cwd = options.cwd ?? process.cwd();
138
+ // F3 Task 8 — memory injection (same shape as every other adapter).
139
+ // Apr 29: caught via the adapter-injection-wiring trip-wire — generic
140
+ // adapters were silently exempt from F3 even though Task 8 said
141
+ // "every spawned agent." Now consistent.
142
+ const injected = applyMemoryPrefix(prompt, cwd, options);
143
+ const finalPrompt = injected.prompt;
137
144
  const snapshot = snapshotCwd(cwd);
138
145
  const aggregator = new ProgressAggregator(cfg.id);
139
146
  let stepsSnapshot = [];
@@ -155,7 +162,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
155
162
  const spawnEnv = { ...process.env, ...(cfg.env ?? {}), ...(options.env ?? {}) };
156
163
  const usesStdin = cfg.prompt_via === 'stdin';
157
164
  const fixedArgs = cfg.args ?? [];
158
- const argv = usesStdin ? [...fixedArgs] : [...fixedArgs, prompt];
165
+ const argv = usesStdin ? [...fixedArgs] : [...fixedArgs, finalPrompt];
159
166
  let child;
160
167
  try {
161
168
  // SAFE · array form (not shell:true) means no shell interpretation.
@@ -172,7 +179,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
172
179
  stdio: [usesStdin ? 'pipe' : 'ignore', 'pipe', 'pipe'],
173
180
  });
174
181
  if (usesStdin) {
175
- child.stdin?.write(prompt);
182
+ child.stdin?.write(finalPrompt);
176
183
  child.stdin?.end();
177
184
  }
178
185
  }
@@ -264,6 +271,7 @@ function spawnGeneric(cfg, prompt, options = {}) {
264
271
  startedAt,
265
272
  prompt,
266
273
  agent: cfg.id,
274
+ memoryInjection: injected.record,
267
275
  get status() {
268
276
  return statusValue;
269
277
  },
@@ -0,0 +1,337 @@
1
+ /**
2
+ * F3 Task 8 — memory injection into spawned-agent prompts.
3
+ *
4
+ * Builds a prompt prefix from the project's `.codehere/` memory layers
5
+ * (AGENTS.md briefing + working/session.md + semantic/lessons.jsonl + a
6
+ * slice of episodic/repo_map.json topSymbols) so spawned agents start
7
+ * warm — with the project's conventions, in-flight context, accumulated
8
+ * lessons, and the most-referenced symbols already in their context window.
9
+ *
10
+ * Pure, side-effect-free, fully unit-tested. Each adapter calls
11
+ * `buildMemoryPrefix(projectRoot)` near the top of `spawn()`, prepends
12
+ * the resulting prefix to the user's prompt, and exposes the metadata via
13
+ * `TaskSession.memoryInjection`.
14
+ *
15
+ * Token budget is char-count-approximated (4 chars ≈ 1 token, the
16
+ * Anthropic / OpenAI rule-of-thumb). Default cap is 3000 tokens —
17
+ * generous but well under any realistic context window. When budget is
18
+ * exceeded, sections are dropped in priority order:
19
+ *
20
+ * keep AGENTS.md — never dropped if user-edited (the briefing
21
+ * is the primary value)
22
+ * drop 3 repo_map slice — symbols are nice-to-have; the agent can
23
+ * discover them via grep if missing
24
+ * drop 2 lessons — useful but not load-bearing
25
+ * drop 1 session.md — useful for hand-off but redundant if the
26
+ * user re-states their task
27
+ *
28
+ * Default-seed detection skips placeholder content (the init seeds for
29
+ * AGENTS.md / session.md). Same byte-equality-based detection pattern as
30
+ * memory-command.ts (per feedback_implementation_patterns_v05.md Pattern #1).
31
+ */
32
+ import { existsSync, readFileSync, realpathSync } from 'fs';
33
+ import { join, dirname, resolve } from 'path';
34
+ const DEFAULT_MAX_TOKENS = 3000;
35
+ const DEFAULT_MAX_LESSONS = 10;
36
+ const DEFAULT_MAX_SYMBOLS = 30;
37
+ // 3 chars per token — conservative ratio per K2 Round 1 feedback. Code
38
+ // tokenizes denser than prose under typical BPE, so the safer-defaults 4/1
39
+ // rule-of-thumb underestimates by 1.5-2x for code-heavy prefixes (which
40
+ // repo_map + lessons.jsonl frequently are). 3/1 stays well clear of model
41
+ // per-message caps without dropping useful content from typical repos.
42
+ const CHARS_PER_TOKEN = 3;
43
+ /** Char-count → token estimate. Conservative — actual tokenization may differ. */
44
+ function estimateTokens(s) {
45
+ return Math.ceil(s.length / CHARS_PER_TOKEN);
46
+ }
47
+ /**
48
+ * Detect whether the given file content is still the init-time placeholder.
49
+ * If so, the user hasn't customized it — injecting the placeholder would
50
+ * just confuse the agent with template instructions.
51
+ *
52
+ * Heuristic: presence of bracketed-instruction lines like `[One-paragraph
53
+ * description...]` or the `> Per-machine, gitignored` boilerplate, AND no
54
+ * substantive non-template content after the markers. Conservative — when
55
+ * in doubt, INCLUDE rather than exclude (better to over-share than miss
56
+ * the briefing).
57
+ */
58
+ function looksLikeInitSeed(content, kind) {
59
+ if (kind === 'agents') {
60
+ // The init AGENTS/CONTEXT seed has these distinctive bracketed placeholders.
61
+ // If at least 2 of them are present, treat as untouched.
62
+ const markers = [
63
+ /\[One-paragraph description of what this repo does/,
64
+ /\[How this codebase is organized/,
65
+ /\[Tone for generated code\./,
66
+ /\[Don't do X\. Always do Y\./,
67
+ ];
68
+ const hits = markers.filter(re => re.test(content)).length;
69
+ return hits >= 2;
70
+ }
71
+ if (kind === 'session') {
72
+ // The init session.md seed has this header + structure
73
+ const markers = [
74
+ /^# Working session/m,
75
+ /^> Per-machine, gitignored/m,
76
+ /^## In flight\s*\n+- \[ \]\s*$/m,
77
+ ];
78
+ const hits = markers.filter(re => re.test(content)).length;
79
+ return hits >= 2;
80
+ }
81
+ return false;
82
+ }
83
+ /**
84
+ * Read the project briefing. Search order (highest-priority first):
85
+ * 1. <projectRoot>/AGENTS.md — agentic-stack convention; if the user
86
+ * has this file at the repo root with real content, it IS their
87
+ * canonical briefing and codehere must respect it (Bug 2 from the
88
+ * Apr 28 EXP-2 dogfood on `~/code/clipa`).
89
+ * 2. <codehereDir>/AGENTS.md — F3-canonical (codehere's own)
90
+ * 3. <codehereDir>/CONTEXT.md — v0.4.1 back-compat
91
+ *
92
+ * Returns the body string + the path that was read (for the prefix
93
+ * header), or null if no usable briefing exists at any tier (every
94
+ * candidate is missing, empty, or looks like the init placeholder).
95
+ */
96
+ function readBriefing(projectRoot, codehereDir) {
97
+ const candidates = [
98
+ { path: join(projectRoot, 'AGENTS.md'), label: 'AGENTS.md (repo root)' },
99
+ { path: join(codehereDir, 'AGENTS.md'), label: '.codehere/AGENTS.md' },
100
+ { path: join(codehereDir, 'CONTEXT.md'), label: '.codehere/CONTEXT.md' },
101
+ ];
102
+ for (const c of candidates) {
103
+ if (existsSync(c.path)) {
104
+ try {
105
+ const body = readFileSync(c.path, 'utf8').trim();
106
+ if (body.length === 0)
107
+ continue;
108
+ if (looksLikeInitSeed(body, 'agents'))
109
+ continue; // placeholder — try next
110
+ return { body, sourcePath: c.label };
111
+ }
112
+ catch {
113
+ continue;
114
+ }
115
+ }
116
+ }
117
+ return null;
118
+ }
119
+ /** Read working/session.md. Skip if seed-shaped or empty. */
120
+ function readSession(codehereDir) {
121
+ const p = join(codehereDir, 'memory/working/session.md');
122
+ if (!existsSync(p))
123
+ return null;
124
+ try {
125
+ const body = readFileSync(p, 'utf8').trim();
126
+ if (body.length === 0)
127
+ return null;
128
+ if (looksLikeInitSeed(body, 'session'))
129
+ return null;
130
+ return body;
131
+ }
132
+ catch {
133
+ return null;
134
+ }
135
+ }
136
+ /** Read up to N lessons from semantic/lessons.jsonl. */
137
+ function readLessons(codehereDir, n) {
138
+ const p = join(codehereDir, 'memory/semantic/lessons.jsonl');
139
+ if (!existsSync(p))
140
+ return [];
141
+ try {
142
+ const body = readFileSync(p, 'utf8');
143
+ return body
144
+ .split('\n')
145
+ .filter(line => line.trim().length > 0)
146
+ .slice(0, n)
147
+ .map(line => {
148
+ try {
149
+ const obj = JSON.parse(line);
150
+ return typeof obj.text === 'string' ? obj.text : line;
151
+ }
152
+ catch {
153
+ return line;
154
+ }
155
+ });
156
+ }
157
+ catch {
158
+ return [];
159
+ }
160
+ }
161
+ function readTopSymbols(codehereDir, n) {
162
+ const p = join(codehereDir, 'memory/episodic/repo_map.json');
163
+ if (!existsSync(p))
164
+ return [];
165
+ try {
166
+ const body = JSON.parse(readFileSync(p, 'utf8'));
167
+ if (!Array.isArray(body.topSymbols))
168
+ return [];
169
+ return body.topSymbols.slice(0, n);
170
+ }
171
+ catch {
172
+ return [];
173
+ }
174
+ }
175
+ /**
176
+ * Format individual sections. Each returns `null` if empty/skipped.
177
+ */
178
+ function formatBriefing(briefing, sourcePath) {
179
+ return `# Project briefing (${sourcePath})\n\n${briefing}\n`;
180
+ }
181
+ function formatSession(session) {
182
+ return `# Current session scratch (.codehere/memory/working/session.md)\n\n${session}\n`;
183
+ }
184
+ function formatLessons(lessons) {
185
+ const items = lessons.map(l => `- ${l}`).join('\n');
186
+ return `# Project lessons (.codehere/memory/semantic/lessons.jsonl)\n\n${items}\n`;
187
+ }
188
+ function formatSymbols(symbols) {
189
+ const lines = symbols.map(s => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
190
+ return `# Top symbols in this repo (.codehere/memory/episodic/repo_map.json)\n\n${lines}\n`;
191
+ }
192
+ /**
193
+ * Build the memory-injection prefix for the project at `projectRoot`.
194
+ *
195
+ * If `.codehere/` doesn't exist, returns empty (nothing to inject).
196
+ * Otherwise reads the four sections, formats each, fits to the token
197
+ * budget by dropping low-priority sections first.
198
+ */
199
+ export function buildMemoryPrefix(projectRoot, options = {}) {
200
+ const codehereDir = join(projectRoot, '.codehere');
201
+ const hasCodehereDir = existsSync(codehereDir);
202
+ const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
203
+ const maxLessons = options.maxLessons ?? DEFAULT_MAX_LESSONS;
204
+ const maxSymbols = options.maxSymbols ?? DEFAULT_MAX_SYMBOLS;
205
+ // Briefing discovery is INDEPENDENT of .codehere/ presence — a pure
206
+ // agentic-stack repo (root AGENTS.md, no .codehere/) is a valid
207
+ // injection source on its own (codex Round 1 fix on Bug 2). Only
208
+ // session/lessons/repo_map require the .codehere/ tree.
209
+ const briefing = readBriefing(projectRoot, codehereDir);
210
+ const session = hasCodehereDir ? readSession(codehereDir) : null;
211
+ const lessons = hasCodehereDir ? readLessons(codehereDir, maxLessons) : [];
212
+ const symbols = hasCodehereDir ? readTopSymbols(codehereDir, maxSymbols) : [];
213
+ // Build candidate sections in PRIORITY order (most-essential first).
214
+ // Drop direction is REVERSE — least-essential drops first.
215
+ const candidates = [];
216
+ if (briefing)
217
+ candidates.push({ key: 'agents', body: formatBriefing(briefing.body, briefing.sourcePath) });
218
+ if (session)
219
+ candidates.push({ key: 'session', body: formatSession(session) });
220
+ if (lessons.length > 0)
221
+ candidates.push({ key: 'lessons', body: formatLessons(lessons) });
222
+ if (symbols.length > 0)
223
+ candidates.push({ key: 'repo_map', body: formatSymbols(symbols) });
224
+ if (candidates.length === 0) {
225
+ return { prefix: '', included: [], tokens: 0, truncated: false };
226
+ }
227
+ // Closing separator, always added at the end.
228
+ const SEPARATOR = '\n---\n\n';
229
+ const separatorTokens = estimateTokens(SEPARATOR);
230
+ // Drop from least-essential (last) until everything fits in budget.
231
+ let truncated = false;
232
+ let kept = [...candidates];
233
+ while (kept.length > 0) {
234
+ const totalChars = kept.reduce((acc, c) => acc + c.body.length, 0);
235
+ const totalTokens = estimateTokens(kept.map(c => c.body).join('\n')) + separatorTokens;
236
+ if (totalTokens <= maxTokens)
237
+ break;
238
+ // Drop the last (least-essential) section.
239
+ kept.pop();
240
+ truncated = true;
241
+ void totalChars;
242
+ }
243
+ // Edge: even AGENTS alone is over budget — truncate AGENTS body itself.
244
+ if (kept.length === 1 && estimateTokens(kept[0].body) + separatorTokens > maxTokens) {
245
+ const room = (maxTokens - separatorTokens) * CHARS_PER_TOKEN;
246
+ const truncatedBody = kept[0].body.slice(0, Math.max(0, room - 32)) + '\n... [truncated to fit]\n';
247
+ kept = [{ key: kept[0].key, body: truncatedBody }];
248
+ truncated = true;
249
+ }
250
+ if (kept.length === 0) {
251
+ return { prefix: '', included: [], tokens: 0, truncated };
252
+ }
253
+ const prefix = kept.map(c => c.body).join('\n') + SEPARATOR;
254
+ return {
255
+ prefix,
256
+ included: kept.map(c => c.key),
257
+ tokens: estimateTokens(prefix),
258
+ truncated,
259
+ };
260
+ }
261
+ /**
262
+ * Walk up from `startPath` looking for the nearest ancestor (inclusive)
263
+ * that codehere can inject memory FROM — meaning EITHER:
264
+ * - has a `.codehere/` directory (codehere-initialized project), OR
265
+ * - has a root-level `AGENTS.md` (agentic-stack convention)
266
+ *
267
+ * Returns the REAL filesystem ancestor path (symlinks resolved), or
268
+ * `null` if no such ancestor exists.
269
+ *
270
+ * codex Round 1 BLOCKER fix: spawning a task from a subdirectory (e.g.
271
+ * `cd src/auth && codehere task ...`) would miss the project root if we
272
+ * only used the immediate cwd. Hence the upward walk.
273
+ *
274
+ * codex Round 2 BLOCKER fix: the upward walk must walk the REAL
275
+ * filesystem ancestry, not the LEXICAL one. Without `realpathSync`,
276
+ * `cwd` inside a symlinked subtree ascends through the lexical path and
277
+ * misses the real project root. Real scenario for iCloud-symlinked /
278
+ * shared-volume / mounted-drive projects.
279
+ *
280
+ * Bug 2 Round 2 BLOCKER fix: also accept a project root that has a
281
+ * root-level `AGENTS.md` even WITHOUT `.codehere/`. A pure agentic-stack
282
+ * repo (clipa-style) is a valid injection source on its own.
283
+ *
284
+ * Stops at the filesystem root. Symlink-following on read is the right
285
+ * asymmetry per ADR-012 (the symlink defense applies to writes, not reads).
286
+ */
287
+ function findProjectRoot(startPath) {
288
+ let current;
289
+ try {
290
+ current = realpathSync(resolve(startPath));
291
+ }
292
+ catch {
293
+ // Broken symlink at startPath itself — fall back to lexical resolve.
294
+ // (Better to attempt some lookup than fail outright.)
295
+ current = resolve(startPath);
296
+ }
297
+ // Bound the loop at the filesystem root by checking dirname stability.
298
+ for (;;) {
299
+ if (existsSync(join(current, '.codehere')) ||
300
+ existsSync(join(current, 'AGENTS.md'))) {
301
+ return current;
302
+ }
303
+ const parent = dirname(current);
304
+ if (parent === current)
305
+ return null;
306
+ current = parent;
307
+ }
308
+ }
309
+ /**
310
+ * Adapter-side convenience: turn a raw user prompt into the spawn-ready
311
+ * prompt + the diagnostic record. Each of the 5 built-in adapters calls
312
+ * this near the top of `spawn()` and uses the returned `prompt` going
313
+ * forward, plus exposes `record` via `TaskSession.memoryInjection`.
314
+ *
315
+ * Walks UPWARD from `cwd` to find the nearest ancestor with `.codehere/`,
316
+ * so spawning from a subdirectory still gets injection (codex Round 1 fix).
317
+ *
318
+ * Returns `{ prompt: <unchanged>, record: undefined }` when:
319
+ * - `options.noMemoryPrefix` is true (caller opted out)
320
+ * - no ancestor of `cwd` has `.codehere/` (nothing to inject)
321
+ * - all sections are empty / placeholder seeds
322
+ */
323
+ export function applyMemoryPrefix(prompt, cwd, options) {
324
+ if (options.noMemoryPrefix)
325
+ return { prompt, record: undefined };
326
+ const projectRoot = findProjectRoot(cwd);
327
+ if (projectRoot === null)
328
+ return { prompt, record: undefined };
329
+ const r = buildMemoryPrefix(projectRoot);
330
+ if (r.prefix === '')
331
+ return { prompt, record: undefined };
332
+ return {
333
+ prompt: r.prefix + prompt,
334
+ record: { included: r.included, tokens: r.tokens, truncated: r.truncated },
335
+ };
336
+ }
337
+ //# sourceMappingURL=memory-injector.js.map
@@ -13,7 +13,11 @@
13
13
  import { spawn, execFileSync } from 'child_process';
14
14
  import { EventEmitter } from 'events';
15
15
  import { randomUUID } from 'crypto';
16
+ import { existsSync } from 'fs';
17
+ import { homedir } from 'os';
18
+ import { join } from 'path';
16
19
  import { ProgressAggregator } from './progress-aggregator.js';
20
+ import { applyMemoryPrefix } from './memory-injector.js';
17
21
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
18
22
  const HEADLESS_FLAGS = ['run', '--format', 'json'];
19
23
  const ERROR_PATTERNS = [
@@ -45,6 +49,9 @@ export function spawnOpenCode(prompt, options = {}) {
45
49
  let statusValue = 'running';
46
50
  let killRequested = false;
47
51
  const cwd = options.cwd ?? process.cwd();
52
+ // F3 Task 8 — memory injection (same shape as every other adapter).
53
+ const injected = applyMemoryPrefix(prompt, cwd, options);
54
+ const finalPrompt = injected.prompt;
48
55
  const snapshot = snapshotCwd(cwd);
49
56
  const aggregator = new ProgressAggregator('opencode');
50
57
  let stepsSnapshot = [];
@@ -115,7 +122,7 @@ export function spawnOpenCode(prompt, options = {}) {
115
122
  let child;
116
123
  try {
117
124
  // OpenCode's `run` expects the message as positional args, not stdin.
118
- child = spawn('opencode', [...HEADLESS_FLAGS, prompt, ...(options.extraArgs ?? [])], {
125
+ child = spawn('opencode', [...HEADLESS_FLAGS, finalPrompt, ...(options.extraArgs ?? [])], {
119
126
  cwd,
120
127
  env: spawnEnv,
121
128
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -206,6 +213,7 @@ export function spawnOpenCode(prompt, options = {}) {
206
213
  startedAt,
207
214
  prompt,
208
215
  agent: 'opencode',
216
+ memoryInjection: injected.record,
209
217
  get status() {
210
218
  return statusValue;
211
219
  },
@@ -266,9 +274,31 @@ export const OpenCodeAdapter = {
266
274
  installHint: { command: 'curl -fsSL https://opencode.ai/install | bash', url: 'https://opencode.ai' },
267
275
  };
268
276
  }
269
- // OpenCode manages its own provider credentials via `opencode providers`.
270
- // Can't statically probe the config without reading its data dir — treat
271
- // install as ready, let runtime codehere:error catch auth failures.
277
+ // OpenCode manages its own provider credentials via `opencode auth`
278
+ // (alias `opencode providers`). The auth file lives at
279
+ // $XDG_DATA_HOME/opencode/auth.json (default ~/.local/share/opencode/auth.json
280
+ // on Linux/macOS) — created with mode 600 the first time a provider is added.
281
+ //
282
+ // Pre-May-1 we returned 'ready' on install alone, deferring to runtime to
283
+ // catch missing-auth via ERROR_PATTERNS.auth_missing. That was dishonest:
284
+ // /api/readiness/agents would say 'ready' → user would spawn → ~50s later
285
+ // the task errored with no clear next step. Surfaced May 1 2026 by the
286
+ // multi-agent dogfood test alongside the same-shape gemini fix.
287
+ const xdgData = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
288
+ const opencodeAuthPath = join(xdgData, 'opencode', 'auth.json');
289
+ if (!existsSync(opencodeAuthPath)) {
290
+ return {
291
+ id: this.id,
292
+ label: this.label,
293
+ state: 'needs_auth',
294
+ installed: true,
295
+ authHint: {
296
+ command: 'opencode auth # interactive provider/key wizard',
297
+ url: 'https://opencode.ai/docs/configuration',
298
+ detail: `OpenCode found on PATH but no provider configured (${opencodeAuthPath} missing). Run \`opencode auth\` once to add an AI provider — supports 75+ providers BYOK including Anthropic, OpenAI, Mistral, etc.`,
299
+ },
300
+ };
301
+ }
272
302
  return { id: this.id, label: this.label, state: 'ready', installed: true };
273
303
  },
274
304
  };
@@ -101,6 +101,8 @@ export class ProgressAggregator {
101
101
  return this.ingestCodex(type, data);
102
102
  if (this.agentId === 'gemini')
103
103
  return this.ingestGemini(type, data);
104
+ if (this.agentId === 'aider')
105
+ return this.ingestAider(type, data);
104
106
  return false;
105
107
  }
106
108
  /** Flush: mark every still-active step as failed (called on abnormal exit). */
@@ -195,6 +197,123 @@ export class ProgressAggregator {
195
197
  }
196
198
  return false;
197
199
  }
200
+ /**
201
+ * Aider observability parity (v0.5 Path B per docs/design/f3_memory_spec.md
202
+ * + ~/.claude/.../memory/learning_aider.md).
203
+ *
204
+ * Aider doesn't expose a structured event stream — we parse stdout lines
205
+ * matching the EXACT patterns aider's `base_coder.py` writes when it
206
+ * applies an edit, creates a new file, or runs a shell command (verified
207
+ * against installed aider 0.86.2 source — line refs below).
208
+ *
209
+ * Patterns we match:
210
+ * "Running <command>" — base_coder.py:2472 — fires AFTER
211
+ * aider's confirm_ask returns yes (which `--yes-always` answers
212
+ * automatically). Aider has already started executing by the time we see
213
+ * this — OBSERVABILITY, not blocking.
214
+ * "Applied edit to <path>" — base_coder.py:2334 — fires after
215
+ * a SEARCH/REPLACE block lands. Used for both edits AND net-new files
216
+ * created via the same flow (aider doesn't distinguish in this message).
217
+ * "Creating empty file <path>" — base_coder.py:461 — fires when
218
+ * aider creates a brand-new empty file as scaffolding before edits.
219
+ *
220
+ * Patterns we DELIBERATELY do NOT match:
221
+ * "Did not apply edit to ..." — explicit non-event; we drop these
222
+ * (would be a false-positive Edit step otherwise).
223
+ * "Create new file?" — that's a CONFIRM_ASK prompt, not a
224
+ * tool_output. With `--yes-always` it never fires interactively, and
225
+ * it's not the action-taken signal anyway.
226
+ *
227
+ * Each matched pattern → one ProgressStep marked done immediately (aider
228
+ * doesn't emit separate completion events; the line itself IS the completion).
229
+ *
230
+ * Label vocabulary is aligned with Claude / Gemini / Codex labels for
231
+ * cross-agent UI consistency: `Run X` for shell, `Edit <path>` for file
232
+ * edits, `Write <path>` for net-new file creation.
233
+ *
234
+ * ANSI handling: HEADLESS_FLAGS in aider-adapter.ts include `--no-pretty`
235
+ * which strips colors. As defense-in-depth (in case extraArgs override that
236
+ * or aider regresses), we strip ANSI escape sequences before matching.
237
+ *
238
+ * Limitation: codehere CANNOT block aider's actions via this path. The
239
+ * upstream-PR Path A (adding `--pre-action-hook` to aider) is what enables
240
+ * blocking parity. v0.5 ships observability; v0.5.x or v0.6 ships blocking
241
+ * once Path A merges.
242
+ */
243
+ // eslint-disable-next-line no-control-regex
244
+ static ANSI_RE = /\x1b\[[0-9;]*m/g;
245
+ /**
246
+ * Per-instance set of file paths aider just created via "Creating empty file ...".
247
+ * Aider emits BOTH `Creating empty file <p>` AND `Applied edit to <p>` for one
248
+ * logical net-new-file creation (file gets touched, then initial content
249
+ * gets applied). Without this set we'd record two steps (`Write <p>` then
250
+ * `Edit <p>`) for one action — codex Round 2 caught this. We suppress the
251
+ * subsequent `Applied edit to` for the same path so the user sees `Write`
252
+ * once, not `Write` followed by `Edit`.
253
+ */
254
+ aiderJustCreated = new Set();
255
+ ingestAider(type, data) {
256
+ if (type !== 'stdout')
257
+ return false;
258
+ const raw = typeof data.line === 'string' ? data.line : '';
259
+ // Strip ANSI escape codes BEFORE matching so the parser survives a
260
+ // `--no-pretty` regression or override.
261
+ const line = raw.replace(ProgressAggregator.ANSI_RE, '').trim();
262
+ if (!line)
263
+ return false;
264
+ // EXPLICIT NON-EVENT: defense-in-depth guard. The `^Applied edit to` regex
265
+ // below wouldn't match "Did not apply edit to ..." because of the anchor,
266
+ // but if either pattern ever loosens, this short-circuit prevents a false
267
+ // Edit step from creeping in.
268
+ if (/^Did not apply edit to\s+/.test(line))
269
+ return false;
270
+ // "Running <command>" — base_coder.py:2472
271
+ let m = line.match(/^Running\s+(.+)$/);
272
+ if (m) {
273
+ this._steps.push({
274
+ label: truncate('Run ' + m[1]),
275
+ state: 'done',
276
+ startedAt: Date.now(),
277
+ completedAt: Date.now(),
278
+ });
279
+ return true;
280
+ }
281
+ // "Applied edit to <path>" — base_coder.py:2334
282
+ m = line.match(/^Applied edit to\s+(.+)$/);
283
+ if (m) {
284
+ const path = m[1];
285
+ // If aider JUST created this file, the Write step already represents the
286
+ // action; skip the Edit step that aider emits for the initial content.
287
+ // Clear from the set after consuming so subsequent edits to the same
288
+ // path (in this same session) DO show as Edit.
289
+ if (this.aiderJustCreated.has(path)) {
290
+ this.aiderJustCreated.delete(path);
291
+ return false;
292
+ }
293
+ this._steps.push({
294
+ label: 'Edit ' + shortPath(path),
295
+ state: 'done',
296
+ startedAt: Date.now(),
297
+ completedAt: Date.now(),
298
+ });
299
+ return true;
300
+ }
301
+ // "Creating empty file <path>" — base_coder.py:461 — net-new file scaffold
302
+ m = line.match(/^Creating empty file\s+(.+)$/);
303
+ if (m) {
304
+ const path = m[1];
305
+ this.aiderJustCreated.add(path);
306
+ // Use "Write" to align with Claude/Gemini's net-new-file label vocabulary.
307
+ this._steps.push({
308
+ label: 'Write ' + shortPath(path),
309
+ state: 'done',
310
+ startedAt: Date.now(),
311
+ completedAt: Date.now(),
312
+ });
313
+ return true;
314
+ }
315
+ return false;
316
+ }
198
317
  ingestCodex(type, data) {
199
318
  if (type !== 'item.completed')
200
319
  return false;