codehere 0.6.1 → 0.6.2

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 (29) hide show
  1. package/dist/application/use-cases/generate-final-report.js +57 -2
  2. package/dist/application/use-cases/generate-narration.js +9 -3
  3. package/dist/index.js +7 -3
  4. package/dist/infrastructure/cost/budget-config.js +1 -1
  5. package/dist/infrastructure/cost/spend-aggregator.js +5 -5
  6. package/dist/infrastructure/hooks/claude-sdk-hook.js +6 -2
  7. package/dist/infrastructure/orchestration/codex-adapter.js +11 -6
  8. package/dist/infrastructure/orchestration/context-handoff.js +1 -1
  9. package/dist/infrastructure/orchestration/gemini-adapter.js +3 -15
  10. package/dist/infrastructure/orchestration/graphify-context.js +6 -13
  11. package/dist/infrastructure/orchestration/memory-injector.js +11 -16
  12. package/dist/infrastructure/orchestration/opencode-adapter.js +1 -1
  13. package/dist/infrastructure/orchestration/progress-aggregator.js +1 -2
  14. package/dist/infrastructure/orchestration/session-manager.js +48 -23
  15. package/dist/infrastructure/protocols/cloud-sync.js +9 -5
  16. package/dist/infrastructure/protocols/mcp-server-impl.js +15 -5
  17. package/dist/infrastructure/storage/cross-tool-agents.js +19 -2
  18. package/dist/infrastructure/storage/project-thread-migration.js +12 -7
  19. package/dist/infrastructure/storage/project-thread-reader.js +11 -1
  20. package/dist/infrastructure/storage/project-thread-title-backfill.js +6 -2
  21. package/dist/presentation/cli/commands/budget-command.js +2 -2
  22. package/dist/presentation/cli/commands/init-command.js +5 -4
  23. package/dist/presentation/cli/commands/memory-command.js +29 -15
  24. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  25. package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
  26. package/dist/presentation/cli/repl.js +20 -1
  27. package/dist/ui.js +36 -28
  28. package/dist/utils/typo-guard.js +1 -1
  29. package/package.json +1 -1
@@ -61,7 +61,9 @@ ${i.steps.length ? i.steps.map((s) => `- ${s.label} (${s.state})`).join('\n') :
61
61
 
62
62
  Files changed:
63
63
  ${i.artifacts?.files?.length
64
- ? i.artifacts.files.map((f) => `- ${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust: ${f.trust}` : ''})`).join('\n')
64
+ ? i.artifacts.files
65
+ .map((f) => `- ${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust: ${f.trust}` : ''})`)
66
+ .join('\n')
65
67
  : '(no file artifacts captured)'}
66
68
 
67
69
  Token usage: ${i.artifacts?.usage?.inputTokens ?? 0} in / ${i.artifacts?.usage?.outputTokens ?? 0} out
@@ -75,16 +77,69 @@ function selectProviderBinary() {
75
77
  return [override];
76
78
  return ['claude-free', 'claude'];
77
79
  }
80
+ /**
81
+ * UAT-003 — patterns that indicate the wrapped agent's session terminated
82
+ * because of a network / auth / quota failure rather than a real task.
83
+ * When we see these in the would-be summary text, we prepend a plain-English
84
+ * recovery hint instead of letting the raw provider error echo through.
85
+ *
86
+ * Pre-fix: bare `exit` in REPL → Conductor spawned claude-code → real API
87
+ * call failed with "All connection attempts failed Request ID: req_...";
88
+ * Final Report quoted that text verbatim. Per Core Values #4 + #6, this is
89
+ * unacceptable — the user sees a Request ID that means nothing.
90
+ */
91
+ const NETWORK_FAILURE_PATTERNS = /(?:all connection attempts failed|ECONNREFUSED|ENOTFOUND|fetch failed|network error|request id:\s*req_|connection timed out|getaddrinfo)/i;
92
+ function isLikelyNetworkFailureSummary(text) {
93
+ return NETWORK_FAILURE_PATTERNS.test(text);
94
+ }
95
+ function buildErrorRecoveryReport(input, durationMs) {
96
+ // Tailor the hint to the specific failure shape we can detect from input.
97
+ const agentName = input.agent === 'conductor' ? 'the agent codehere chose' : input.agent;
98
+ return {
99
+ summary: `The ${agentName} session ended without completing the task. ` +
100
+ `This usually means a network hiccup, an expired login, or a rate limit on the provider.\n\n` +
101
+ `What to try: check your internet, run \`codehere health\` to confirm reachability + auth, ` +
102
+ `or wait a minute and rerun the same prompt. If it keeps happening, try a different agent ` +
103
+ `from the dropdown — codehere is vendor-neutral on purpose.\n\n` +
104
+ `No files were changed in this attempt.`,
105
+ generatedAt: Date.now(),
106
+ durationMs,
107
+ };
108
+ }
78
109
  /**
79
110
  * Generate a Final Report. Returns null if no provider is reachable.
80
111
  * Never throws — failure modes return null + log a debug line.
112
+ *
113
+ * UAT-003 — when the input.status is 'error', we skip the LLM call entirely
114
+ * and return a deterministic recovery hint. Saves tokens on a known-failed
115
+ * task AND gives the user a plain-English next step instead of waiting for
116
+ * a possibly-unhelpful LLM summary.
81
117
  */
82
118
  export async function generateFinalReport(input) {
119
+ // Short-circuit on errored sessions — don't waste tokens summarizing nothing.
120
+ if (input.status === 'error') {
121
+ return buildErrorRecoveryReport(input, 0);
122
+ }
83
123
  const prompt = PROMPT_TEMPLATE(input);
84
124
  for (const binary of selectProviderBinary()) {
85
125
  const result = await tryProvider(binary, prompt, input.sessionId);
86
- if (result)
126
+ if (result) {
127
+ // Defense-in-depth: even on 'completed' status, if the generated
128
+ // summary leaks a provider network-failure string (e.g., the wrapped
129
+ // agent exited 0 but its assistant text was an error message), wrap
130
+ // the summary with a recovery preamble.
131
+ if (isLikelyNetworkFailureSummary(result.summary)) {
132
+ return {
133
+ summary: `Couldn't reach the provider cleanly — the agent's session text looks like a network or auth failure.\n\n` +
134
+ `What to try: check your internet, run \`codehere health\`, or rerun in a minute. ` +
135
+ `If it persists, switch agents from the dropdown.\n\n` +
136
+ `— Raw agent message —\n${result.summary}`,
137
+ generatedAt: result.generatedAt,
138
+ durationMs: result.durationMs,
139
+ };
140
+ }
87
141
  return result;
142
+ }
88
143
  }
89
144
  return null;
90
145
  }
@@ -75,11 +75,17 @@ function tryProvider(binary, prompt, sessionId, stepCount) {
75
75
  try {
76
76
  child.kill();
77
77
  }
78
- catch { /* ignore */ }
78
+ catch {
79
+ /* ignore */
80
+ }
79
81
  finish(null);
80
82
  }, 20_000);
81
- child.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf-8'); });
82
- child.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf-8'); });
83
+ child.stdout?.on('data', (chunk) => {
84
+ stdout += chunk.toString('utf-8');
85
+ });
86
+ child.stderr?.on('data', (chunk) => {
87
+ stderr += chunk.toString('utf-8');
88
+ });
83
89
  child.on('close', (code) => {
84
90
  clearTimeout(timeout);
85
91
  if (code !== 0) {
package/dist/index.js CHANGED
@@ -77,7 +77,7 @@ program
77
77
  // typo guard miss the unknown-command shape. See typo-guard.ts.
78
78
  .argument('[query...]', 'Optional initial task to delegate (otherwise enter interactive REPL)')
79
79
  .action(async (queryParts) => {
80
- const joined = (queryParts && queryParts.length > 0) ? queryParts.join(' ') : '';
80
+ const joined = queryParts && queryParts.length > 0 ? queryParts.join(' ') : '';
81
81
  // One-shot delegate: `codehere "review src/auth.ts"` runs that task and exits.
82
82
  // Useful for scripting or quick one-offs without entering the REPL.
83
83
  if (joined && joined.trim()) {
@@ -581,13 +581,17 @@ program
581
581
  const { SessionManager } = require('./infrastructure/orchestration/session-manager.js');
582
582
  SessionManager.getInstance().shutdown();
583
583
  }
584
- catch { /* graceful degradation — daemon may not have started fully */ }
584
+ catch {
585
+ /* graceful degradation — daemon may not have started fully */
586
+ }
585
587
  if (cloud) {
586
588
  try {
587
589
  const { stopCloudSync } = require('./infrastructure/protocols/cloud-sync.js');
588
590
  stopCloudSync();
589
591
  }
590
- catch { /* cloud sync may not be loaded */ }
592
+ catch {
593
+ /* cloud sync may not be loaded */
594
+ }
591
595
  }
592
596
  server.close();
593
597
  };
@@ -84,7 +84,7 @@ export function saveBudget(cfg) {
84
84
  mkdirSync(dir, { recursive: true });
85
85
  const lines = [
86
86
  '# codehere daily-spend budget. Edit by hand or via `codehere budget set <amount>`.',
87
- '# daily_usd: hard ceiling for today\'s estimated spend. Set to a number to enable.',
87
+ "# daily_usd: hard ceiling for today's estimated spend. Set to a number to enable.",
88
88
  '# Leave commented out / omit to disable budget enforcement entirely.',
89
89
  ];
90
90
  if (cfg.dailyUsd !== null) {
@@ -36,11 +36,11 @@ function sessionsFile() {
36
36
  * under-promise and miss a real bill. */
37
37
  const COST_PER_M_OUTPUT_TOKENS_USD = {
38
38
  'claude-code': 15.0, // Sonnet 4.6 baseline
39
- 'codex': 10.0, // GPT-5 / codex baseline
40
- 'aider': 15.0, // typically Sonnet
41
- 'gemini': 0, // free tier (Google AI Studio)
42
- 'opencode': 0, // typically free local / NIM-routed
43
- 'conductor': 15.0, // routes to Claude Code by default
39
+ codex: 10.0, // GPT-5 / codex baseline
40
+ aider: 15.0, // typically Sonnet
41
+ gemini: 0, // free tier (Google AI Studio)
42
+ opencode: 0, // typically free local / NIM-routed
43
+ conductor: 15.0, // routes to Claude Code by default
44
44
  };
45
45
  const INPUT_RATE_RATIO = 0.2; // input ≈ 1/5 of output
46
46
  const CACHE_READ_RATIO = 0.1; // cache reads ≈ 1/10 of input (so 1/50 output)
@@ -517,7 +517,9 @@ export function appendAuditLog(entry) {
517
517
  catch {
518
518
  // Lock held by another process — spin-wait ~10ms
519
519
  const deadline = Date.now() + 10;
520
- while (Date.now() < deadline) { /* busy-wait — sync context */ }
520
+ while (Date.now() < deadline) {
521
+ /* busy-wait — sync context */
522
+ }
521
523
  }
522
524
  }
523
525
  return false;
@@ -526,7 +528,9 @@ export function appendAuditLog(entry) {
526
528
  try {
527
529
  unlinkSync(LOCK_FILE);
528
530
  }
529
- catch { /* best effort */ }
531
+ catch {
532
+ /* best effort */
533
+ }
530
534
  };
531
535
  if (!lockAcquired()) {
532
536
  // Fall through unlocked rather than drop the entry. The entry still
@@ -216,9 +216,7 @@ export function spawnCodex(prompt, options = {}) {
216
216
  continue;
217
217
  try {
218
218
  const data = JSON.parse(line);
219
- if (codexThreadId === undefined &&
220
- data.type === 'thread.started' &&
221
- typeof data.thread_id === 'string') {
219
+ if (codexThreadId === undefined && data.type === 'thread.started' && typeof data.thread_id === 'string') {
222
220
  codexThreadId = data.thread_id;
223
221
  }
224
222
  emit({ type: data.type ?? 'codex_event', timestamp: Date.now(), data });
@@ -325,8 +323,15 @@ export function spawnCodex(prompt, options = {}) {
325
323
  let stdout = '';
326
324
  let stderr = '';
327
325
  // codex exec resume <SESSION_ID> - → reads prompt from stdin
328
- const followup = spawn('codex', ['exec', '--json', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox',
329
- 'resume', codexThreadId, '-'], {
326
+ const followup = spawn('codex', [
327
+ 'exec',
328
+ '--json',
329
+ '--skip-git-repo-check',
330
+ '--dangerously-bypass-approvals-and-sandbox',
331
+ 'resume',
332
+ codexThreadId,
333
+ '-',
334
+ ], {
330
335
  cwd,
331
336
  env: spawnEnv,
332
337
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -371,7 +376,7 @@ export function spawnCodex(prompt, options = {}) {
371
376
  export const CodexAdapter = {
372
377
  id: 'codex',
373
378
  label: 'Codex',
374
- description: 'OpenAI\'s coding agent. Good when the task needs reading a lot of code first, then changing several files together.',
379
+ description: "OpenAI's coding agent. Good when the task needs reading a lot of code first, then changing several files together.",
375
380
  processPattern: /(?:^|\/)codex(?:\s|$)/,
376
381
  isInstalled() {
377
382
  try {
@@ -58,7 +58,7 @@ export function loadHandoffContext(cwd, { fromSessionId, maxAgeMs = DEFAULT_MAX_
58
58
  // Filter by age
59
59
  const recent = packs.filter((p) => {
60
60
  const ts = p.timestamp;
61
- return ts && (now - ts) < maxAgeMs;
61
+ return ts && now - ts < maxAgeMs;
62
62
  });
63
63
  // Prefer packs with enough graph structure to be useful
64
64
  const structured = recent.filter((p) => {
@@ -48,13 +48,7 @@ import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
48
48
  //
49
49
  // `--skip-trust` — skip the per-workspace trust prompt that gemini shows on
50
50
  // first run in a new directory.
51
- const HEADLESS_FLAGS = [
52
- '--approval-mode',
53
- 'yolo',
54
- '--skip-trust',
55
- '-o',
56
- 'stream-json',
57
- ];
51
+ const HEADLESS_FLAGS = ['--approval-mode', 'yolo', '--skip-trust', '-o', 'stream-json'];
58
52
  // Error patterns scoped to STDERR / result.error / error events only — NOT
59
53
  // every JSON-serialized event (which would false-positive on tool output).
60
54
  const ERROR_PATTERNS = [
@@ -340,13 +334,7 @@ export function spawnGemini(prompt, options = {}) {
340
334
  }
341
335
  }
342
336
  statusValue =
343
- killRequested || signal !== null
344
- ? 'killed'
345
- : errorEmitted
346
- ? 'error'
347
- : code === 0
348
- ? 'completed'
349
- : 'error';
337
+ killRequested || signal !== null ? 'killed' : errorEmitted ? 'error' : code === 0 ? 'completed' : 'error';
350
338
  if (aggregator.finalize(statusValue === 'completed' ? 'completed' : statusValue === 'killed' ? 'killed' : 'error')) {
351
339
  stepsSnapshot = aggregator.steps.map((s) => ({ ...s }));
352
340
  emit({ type: 'codehere:steps', timestamp: Date.now(), data: { steps: stepsSnapshot } });
@@ -518,7 +506,7 @@ export function spawnGemini(prompt, options = {}) {
518
506
  export const GeminiAdapter = {
519
507
  id: 'gemini',
520
508
  label: 'Gemini',
521
- description: 'Google\'s coding agent. Has a free tier and can read very large amounts of code or docs at once. Useful when the paid agents are out of credits.',
509
+ description: "Google's coding agent. Has a free tier and can read very large amounts of code or docs at once. Useful when the paid agents are out of credits.",
522
510
  // Match `gemini`, `node /path/.../gemini`, and `./gemini` invocations.
523
511
  processPattern: /(?:^|\/)gemini(?:\s|$)|node\s+\S*gemini/,
524
512
  isInstalled() {
@@ -74,7 +74,7 @@ export function buildGraphSync(cwd, { incremental = true } = {}) {
74
74
  * Compile a compact context pack for a prompt + workspace.
75
75
  * Synchronous — calls graphify-ts pack and returns the payload.
76
76
  */
77
- export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000, } = {}) {
77
+ export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000 } = {}) {
78
78
  const startTime = Date.now();
79
79
  const graphPath = getGraphPath(cwd);
80
80
  // Build graph if it doesn't exist yet
@@ -86,12 +86,7 @@ export function compileContextPackSync(prompt, cwd, { task = 'explain', budget =
86
86
  return null;
87
87
  }
88
88
  try {
89
- const output = execFileSync('npx', [
90
- 'graphify-ts', 'pack', prompt,
91
- '--graph', graphPath,
92
- '--task', task,
93
- '--budget', String(budget),
94
- ], {
89
+ const output = execFileSync('npx', ['graphify-ts', 'pack', prompt, '--graph', graphPath, '--task', task, '--budget', String(budget)], {
95
90
  cwd,
96
91
  encoding: 'utf-8',
97
92
  maxBuffer: 512 * 1024,
@@ -125,7 +120,9 @@ export function compileSessionPackSync(cwd, { prompt, artifacts, steps, agent })
125
120
  agent ? `Agent: ${agent}` : undefined,
126
121
  artifacts ? `Artifacts:\n${artifacts}` : undefined,
127
122
  steps ? `Steps taken:\n${steps}` : undefined,
128
- ].filter(Boolean).join('\n\n');
123
+ ]
124
+ .filter(Boolean)
125
+ .join('\n\n');
129
126
  return compileContextPackSync(sessionContext, cwd, { task: 'impact', budget: 2500 });
130
127
  }
131
128
  // ── Context pack persistence ───────────────────────────────────────────────────
@@ -232,11 +229,7 @@ export function queryGraphSync(question, cwd, { maxTokens = 2000 } = {}) {
232
229
  return null;
233
230
  }
234
231
  try {
235
- const output = execFileSync('npx', [
236
- 'graphify-ts', 'query', question,
237
- '--graph', graphPath,
238
- '--budget', String(maxTokens),
239
- ], {
232
+ const output = execFileSync('npx', ['graphify-ts', 'query', question, '--graph', graphPath, '--budget', String(maxTokens)], {
240
233
  cwd,
241
234
  encoding: 'utf-8',
242
235
  maxBuffer: 256 * 1024,
@@ -65,17 +65,13 @@ function looksLikeInitSeed(content, kind) {
65
65
  /\[Tone for generated code\./,
66
66
  /\[Don't do X\. Always do Y\./,
67
67
  ];
68
- const hits = markers.filter(re => re.test(content)).length;
68
+ const hits = markers.filter((re) => re.test(content)).length;
69
69
  return hits >= 2;
70
70
  }
71
71
  if (kind === 'session') {
72
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;
73
+ const markers = [/^# Working session/m, /^> Per-machine, gitignored/m, /^## In flight\s*\n+- \[ \]\s*$/m];
74
+ const hits = markers.filter((re) => re.test(content)).length;
79
75
  return hits >= 2;
80
76
  }
81
77
  return false;
@@ -142,9 +138,9 @@ function readLessons(codehereDir, n) {
142
138
  const body = readFileSync(p, 'utf8');
143
139
  return body
144
140
  .split('\n')
145
- .filter(line => line.trim().length > 0)
141
+ .filter((line) => line.trim().length > 0)
146
142
  .slice(0, n)
147
- .map(line => {
143
+ .map((line) => {
148
144
  try {
149
145
  const obj = JSON.parse(line);
150
146
  return typeof obj.text === 'string' ? obj.text : line;
@@ -182,11 +178,11 @@ function formatSession(session) {
182
178
  return `# Current session scratch (.codehere/memory/working/session.md)\n\n${session}\n`;
183
179
  }
184
180
  function formatLessons(lessons) {
185
- const items = lessons.map(l => `- ${l}`).join('\n');
181
+ const items = lessons.map((l) => `- ${l}`).join('\n');
186
182
  return `# Project lessons (.codehere/memory/semantic/lessons.jsonl)\n\n${items}\n`;
187
183
  }
188
184
  function formatSymbols(symbols) {
189
- const lines = symbols.map(s => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
185
+ const lines = symbols.map((s) => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
190
186
  return `# Top symbols in this repo (.codehere/memory/episodic/repo_map.json)\n\n${lines}\n`;
191
187
  }
192
188
  /**
@@ -232,7 +228,7 @@ export function buildMemoryPrefix(projectRoot, options = {}) {
232
228
  let kept = [...candidates];
233
229
  while (kept.length > 0) {
234
230
  const totalChars = kept.reduce((acc, c) => acc + c.body.length, 0);
235
- const totalTokens = estimateTokens(kept.map(c => c.body).join('\n')) + separatorTokens;
231
+ const totalTokens = estimateTokens(kept.map((c) => c.body).join('\n')) + separatorTokens;
236
232
  if (totalTokens <= maxTokens)
237
233
  break;
238
234
  // Drop the last (least-essential) section.
@@ -250,10 +246,10 @@ export function buildMemoryPrefix(projectRoot, options = {}) {
250
246
  if (kept.length === 0) {
251
247
  return { prefix: '', included: [], tokens: 0, truncated };
252
248
  }
253
- const prefix = kept.map(c => c.body).join('\n') + SEPARATOR;
249
+ const prefix = kept.map((c) => c.body).join('\n') + SEPARATOR;
254
250
  return {
255
251
  prefix,
256
- included: kept.map(c => c.key),
252
+ included: kept.map((c) => c.key),
257
253
  tokens: estimateTokens(prefix),
258
254
  truncated,
259
255
  };
@@ -296,8 +292,7 @@ function findProjectRoot(startPath) {
296
292
  }
297
293
  // Bound the loop at the filesystem root by checking dirname stability.
298
294
  for (;;) {
299
- if (existsSync(join(current, '.codehere')) ||
300
- existsSync(join(current, 'AGENTS.md'))) {
295
+ if (existsSync(join(current, '.codehere')) || existsSync(join(current, 'AGENTS.md'))) {
301
296
  return current;
302
297
  }
303
298
  const parent = dirname(current);
@@ -434,7 +434,7 @@ export function spawnOpenCode(prompt, options = {}) {
434
434
  export const OpenCodeAdapter = {
435
435
  id: 'opencode',
436
436
  label: 'OpenCode',
437
- description: 'Runs several agents at once for genuinely independent jobs. You won\'t see live progress while it works — the trade-off for the speed of doing things in parallel.',
437
+ description: "Runs several agents at once for genuinely independent jobs. You won't see live progress while it works — the trade-off for the speed of doing things in parallel.",
438
438
  processPattern: /(?:^|\/)opencode(?:\s|$)/,
439
439
  isInstalled() {
440
440
  try {
@@ -464,8 +464,7 @@ export class ProgressAggregator {
464
464
  if (itemType === 'tool_call' || itemType === 'local_shell_call') {
465
465
  const name = String(item.tool_name ?? item.name ?? 'tool');
466
466
  // Tool-call items may carry a `result` or `output` field too.
467
- const result = (typeof item.result === 'string' ? item.result : '') ||
468
- (typeof item.output === 'string' ? item.output : '');
467
+ const result = (typeof item.result === 'string' ? item.result : '') || (typeof item.output === 'string' ? item.output : '');
469
468
  this._steps.push({
470
469
  label: truncate(name),
471
470
  state: 'done',
@@ -22,14 +22,14 @@ import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js'
22
22
  import { buildConductorAdapter } from './conductor-adapter.js';
23
23
  import { seedAgentsConfig } from './agents-config-seed.js';
24
24
  import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
25
- import { appendSpawned, appendCompleted, appendFinalReport, loadRecent } from './session-persistence.js';
25
+ import { appendSpawned, appendCompleted, appendFinalReport, loadRecent, } from './session-persistence.js';
26
26
  import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
27
27
  import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/project-thread-mirror.js';
28
28
  import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
29
29
  import { generateNarration } from '../../application/use-cases/generate-narration.js';
30
30
  import { randomUUID } from 'crypto';
31
31
  import { classifyFailoverEligibility } from './failover-classifier.js';
32
- import { resolveFailoverTarget, toAvailability, } from './failover-policy.js';
32
+ import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
33
33
  import { loadFailoverConfig, resolveChain } from './failover-config.js';
34
34
  import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
35
35
  import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
@@ -39,7 +39,13 @@ import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
39
39
  // order. Built-in adapters first; user-config-driven ones appended from
40
40
  // ~/.codehere/agents.yaml (generic-cli-adapter). isInstalled() gates visibility
41
41
  // so an adapter registering for an un-installed CLI is harmless.
42
- const BUILTIN_REGISTRY = [ClaudeCodeAdapter, CodexAdapter, AiderAdapter, OpenCodeAdapter, GeminiAdapter];
42
+ const BUILTIN_REGISTRY = [
43
+ ClaudeCodeAdapter,
44
+ CodexAdapter,
45
+ AiderAdapter,
46
+ OpenCodeAdapter,
47
+ GeminiAdapter,
48
+ ];
43
49
  // Seed the default agents.yaml if this user has never configured one, so Gemini
44
50
  // / Cursor / Pi / Antigravity / Goose appear in the registry pre-wired. They
45
51
  // stay hidden in the UI until isInstalled() flips to true. Idempotent.
@@ -149,16 +155,16 @@ export class SessionManager {
149
155
  * reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
150
156
  failoverOffers;
151
157
  /** Tracks the failover error code per session so acceptFailover can emit
152
- * the correct reason in the thread event. Populated by handleFailover. */
158
+ * the correct reason in the thread event. Populated by handleFailover. */
153
159
  failoverReasonBySession = new Map();
154
160
  /** Session threading — tracks which session continued as which after failover.
155
- * continuedAsBySession: original → new session ID
156
- * continuedFromBySession: new → original session ID */
161
+ * continuedAsBySession: original → new session ID
162
+ * continuedFromBySession: new → original session ID */
157
163
  continuedAsBySession = new Map();
158
164
  continuedFromBySession = new Map();
159
165
  /** Final Report failure tracking — when generateFinalReport rejects, we
160
- * record it so the API can return a definitive "failed" status instead
161
- * of leaving the UI showing "Generating report…" forever. */
166
+ * record it so the API can return a definitive "failed" status instead
167
+ * of leaving the UI showing "Generating report…" forever. */
162
168
  failedReportBySession = new Set();
163
169
  spawn(prompt, agentId = 'claude-code', options) {
164
170
  const adapter = getAdapter(agentId);
@@ -305,7 +311,10 @@ export class SessionManager {
305
311
  // or pack compilation may fail. All degrade silently.
306
312
  void Promise.resolve().then(() => {
307
313
  try {
308
- const stepsStr = session.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
314
+ const stepsStr = session.steps
315
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
316
+ .filter(Boolean)
317
+ .join('\n') ?? '';
309
318
  const pack = compileSessionPackSync(cwd, {
310
319
  prompt: session.prompt,
311
320
  artifacts: JSON.stringify(session.artifacts, null, 2),
@@ -446,14 +455,21 @@ export class SessionManager {
446
455
  try {
447
456
  printFinalReportToTerminal(reportInput, report);
448
457
  }
449
- catch { /* defensive */ }
458
+ catch {
459
+ /* defensive */
460
+ }
450
461
  }
451
462
  })
452
- .catch(() => { });
463
+ .catch(() => {
464
+ /* defensive */
465
+ });
453
466
  // P1 — persist context pack at failover-replacement session exit.
454
467
  void Promise.resolve().then(() => {
455
468
  try {
456
- const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
469
+ const stepsStr = newSession.steps
470
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
471
+ .filter(Boolean)
472
+ .join('\n') ?? '';
457
473
  const pack = compileSessionPackSync(cwd, {
458
474
  prompt: newSession.prompt,
459
475
  artifacts: JSON.stringify(newSession.artifacts, null, 2),
@@ -546,9 +562,9 @@ export class SessionManager {
546
562
  return offers;
547
563
  }
548
564
  /** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
549
- * session on the target agent, preserving the original prompt + cwd.
550
- * P2 — injects handoff context for cross-agent continuity.
551
- * Returns the new session or undefined if the target is unavailable. */
565
+ * session on the target agent, preserving the original prompt + cwd.
566
+ * P2 — injects handoff context for cross-agent continuity.
567
+ * Returns the new session or undefined if the target is unavailable. */
552
568
  acceptFailover(sessionId, targetAgent) {
553
569
  const session = this.sessions.get(sessionId);
554
570
  if (!session)
@@ -631,14 +647,21 @@ export class SessionManager {
631
647
  try {
632
648
  printFinalReportToTerminal(reportInput, report);
633
649
  }
634
- catch { /* defensive */ }
650
+ catch {
651
+ /* defensive */
652
+ }
635
653
  }
636
654
  })
637
- .catch(() => { });
655
+ .catch(() => {
656
+ /* defensive */
657
+ });
638
658
  // P1 — persist context pack at accept-failover replacement session exit.
639
659
  void Promise.resolve().then(() => {
640
660
  try {
641
- const stepsStr = newSession.steps?.map((s) => `${s.label}: ${s.output || s.state}`).filter(Boolean).join('\n') ?? '';
661
+ const stepsStr = newSession.steps
662
+ ?.map((s) => `${s.label}: ${s.output || s.state}`)
663
+ .filter(Boolean)
664
+ .join('\n') ?? '';
642
665
  const pack = compileSessionPackSync(cwd, {
643
666
  prompt: newSession.prompt,
644
667
  artifacts: JSON.stringify(newSession.artifacts, null, 2),
@@ -718,7 +741,9 @@ export class SessionManager {
718
741
  if (out)
719
742
  this.narrationBySession.set(session.sessionId, out);
720
743
  })
721
- .catch(() => { });
744
+ .catch(() => {
745
+ /* defensive — generateNarration never throws */
746
+ });
722
747
  }, intervalMs);
723
748
  this.narrationTimers.set(session.sessionId, timer);
724
749
  }
@@ -750,14 +775,14 @@ export class SessionManager {
750
775
  return this.continuedAsBySession.get(sessionId);
751
776
  }
752
777
  /** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
753
- * Returns undefined while generation is in flight OR if the provider chain
754
- * failed (no claude-free, no claude, no credit, etc). The web UI reads this
755
- * through the /api/tasks serializer to render the prose summary inline. */
778
+ * Returns undefined while generation is in flight OR if the provider chain
779
+ * failed (no claude-free, no claude, no credit, etc). The web UI reads this
780
+ * through the /api/tasks serializer to render the prose summary inline. */
756
781
  getFinalReport(sessionId) {
757
782
  return this.finalReportBySession.get(sessionId);
758
783
  }
759
784
  /** Returns true if Final Report generation explicitly failed for this session.
760
- * Lets the API distinguish "not yet" from "never coming". */
785
+ * Lets the API distinguish "not yet" from "never coming". */
761
786
  hasFailedReport(sessionId) {
762
787
  return this.failedReportBySession.has(sessionId);
763
788
  }
@@ -62,7 +62,7 @@ export function collectSessionMetadata() {
62
62
  }
63
63
  try {
64
64
  const content = readFileSync(SESSIONS_FILE, 'utf-8');
65
- const lines = content.split('\n').filter(l => l.trim().length > 0);
65
+ const lines = content.split('\n').filter((l) => l.trim().length > 0);
66
66
  // Parse records and pair spawned + completed
67
67
  const spawnedMap = new Map();
68
68
  const completedMap = new Map();
@@ -89,9 +89,13 @@ export function collectSessionMetadata() {
89
89
  const completed = completedMap.get(id);
90
90
  const finalReport = finalReportMap.get(id);
91
91
  const status = completed
92
- ? (completed.status === 'completed' ? 'done' : completed.status === 'failed' ? 'failed' : 'done')
92
+ ? completed.status === 'completed'
93
+ ? 'done'
94
+ : completed.status === 'failed'
95
+ ? 'failed'
96
+ : 'done'
93
97
  : 'running';
94
- const durationMs = completed?.durationMs ?? (Date.now() - spawned.startedAt);
98
+ const durationMs = completed?.durationMs ?? Date.now() - spawned.startedAt;
95
99
  const tools = completed?.steps?.length ?? 0;
96
100
  const files = completed?.artifacts?.filesChanged?.length ?? 0;
97
101
  const trustScore = extractTrustScore(completed?.artifacts);
@@ -151,7 +155,7 @@ export function startCloudSync(machineName) {
151
155
  console.error(` Cloud sync failed: ${res.status} ${body}`);
152
156
  }
153
157
  else {
154
- const data = await res.json();
158
+ const data = (await res.json());
155
159
  if (data.sessionsPushed && data.sessionsPushed > 0) {
156
160
  console.log(` Cloud sync: ${data.sessionsPushed} session(s) pushed`);
157
161
  }
@@ -166,7 +170,7 @@ export function startCloudSync(machineName) {
166
170
  const res = await fetch(`${CLOUD_URL}?action=pull-tasks&machineId=${machineId}`);
167
171
  if (!res.ok)
168
172
  return;
169
- const data = await res.json();
173
+ const data = (await res.json());
170
174
  const tasks = data.tasks || [];
171
175
  for (const task of tasks) {
172
176
  if (task.prompt) {
@@ -439,7 +439,7 @@ export function startMCPServer(options = {}) {
439
439
  // Send current session state on connect
440
440
  try {
441
441
  const manager = SessionManager.getInstance();
442
- const sessions = manager.list().map(s => ({
442
+ const sessions = manager.list().map((s) => ({
443
443
  sessionId: s.sessionId,
444
444
  status: s.status,
445
445
  agent: s.agent,
@@ -455,7 +455,9 @@ export function startMCPServer(options = {}) {
455
455
  try {
456
456
  lastSize = statSync(SESSIONS_FILE).size;
457
457
  }
458
- catch { /* first time */ }
458
+ catch {
459
+ /* first time */
460
+ }
459
461
  }
460
462
  const onChange = () => {
461
463
  try {
@@ -467,7 +469,9 @@ export function startMCPServer(options = {}) {
467
469
  lastSize = currentSize;
468
470
  res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
469
471
  }
470
- catch { /* transient */ }
472
+ catch {
473
+ /* transient */
474
+ }
471
475
  };
472
476
  watchFile(SESSIONS_FILE, { interval: 500 }, onChange);
473
477
  const ping = setInterval(() => res.write(': ping\n\n'), 15000);
@@ -1257,7 +1261,9 @@ export function startMCPServer(options = {}) {
1257
1261
  });
1258
1262
  }
1259
1263
  }
1260
- catch { /* mirror is best-effort */ }
1264
+ catch {
1265
+ /* mirror is best-effort */
1266
+ }
1261
1267
  res.writeHead(200, { 'Content-Type': 'application/json' });
1262
1268
  res.end(JSON.stringify({ sessionId, ...out }));
1263
1269
  }
@@ -1325,7 +1331,11 @@ export function startMCPServer(options = {}) {
1325
1331
  }
1326
1332
  else {
1327
1333
  res.writeHead(200, { 'Content-Type': 'application/json' });
1328
- res.end(JSON.stringify({ ok: false, error: 'target_unavailable', detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.` }));
1334
+ res.end(JSON.stringify({
1335
+ ok: false,
1336
+ error: 'target_unavailable',
1337
+ detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.`,
1338
+ }));
1329
1339
  }
1330
1340
  }
1331
1341
  catch (e) {
@@ -143,7 +143,10 @@ export function parseAgentFile(filePath, defaultFormat = 'unknown') {
143
143
  // Tools normalization
144
144
  let tools = null;
145
145
  if (typeof fm.tools === 'string') {
146
- tools = fm.tools.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
146
+ tools = fm.tools
147
+ .split(',')
148
+ .map((t) => t.trim().toLowerCase())
149
+ .filter(Boolean);
147
150
  }
148
151
  else if (hasObjectTools) {
149
152
  tools = [];
@@ -162,7 +165,21 @@ export function parseAgentFile(filePath, defaultFormat = 'unknown') {
162
165
  ? 'subagent'
163
166
  : 'all';
164
167
  // Track unknown frontmatter keys (everything outside our normalized set)
165
- const knownKeys = new Set(['name', 'description', 'mode', 'model', 'temperature', 'tools', 'permission', 'color', 'hidden', 'top_p', 'steps', 'prompt', 'disable']);
168
+ const knownKeys = new Set([
169
+ 'name',
170
+ 'description',
171
+ 'mode',
172
+ 'model',
173
+ 'temperature',
174
+ 'tools',
175
+ 'permission',
176
+ 'color',
177
+ 'hidden',
178
+ 'top_p',
179
+ 'steps',
180
+ 'prompt',
181
+ 'disable',
182
+ ]);
166
183
  const unknownKeys = Object.keys(fm).filter((k) => !knownKeys.has(k));
167
184
  return {
168
185
  name: typeof fm.name === 'string' ? fm.name : filenameName,
@@ -32,12 +32,7 @@ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync
32
32
  import { join } from 'path';
33
33
  import { createHash } from 'crypto';
34
34
  /** Path patterns considered ephemeral and skipped from project list. */
35
- const DEFAULT_TEMP_DIR_PATTERNS = [
36
- /^\/tmp\//,
37
- /^\/var\/folders\//,
38
- /^\/private\/var\/folders\//,
39
- /^\/private\/tmp\//,
40
- ];
35
+ const DEFAULT_TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
41
36
  /** sha1(cwd)[:12] — stable, short, filesystem-safe. */
42
37
  function slugForCwd(cwd, unknownLabel) {
43
38
  if (!cwd)
@@ -79,7 +74,17 @@ function rollupSessions(events) {
79
74
  completedIds.add(id);
80
75
  const existing = byId.get(id) ?? { id, abandoned: true };
81
76
  const existingAny = existing;
82
- for (const k of ['agent', 'prompt', 'cwd', 'startedAt', 'status', 'durationMs', 'completedAt', 'steps', 'artifacts']) {
77
+ for (const k of [
78
+ 'agent',
79
+ 'prompt',
80
+ 'cwd',
81
+ 'startedAt',
82
+ 'status',
83
+ 'durationMs',
84
+ 'completedAt',
85
+ 'steps',
86
+ 'artifacts',
87
+ ]) {
83
88
  if (e[k] !== undefined && existingAny[k] === undefined) {
84
89
  existingAny[k] = e[k];
85
90
  }
@@ -133,7 +133,17 @@ export function readProjectThread(slug, options) {
133
133
  };
134
134
  // Merge fields — later entries override earlier (covers
135
135
  // spawn → completion update pattern from forward-mirror)
136
- for (const k of ['agent', 'prompt', 'cwd', 'startedAt', 'status', 'durationMs', 'completedAt', 'steps', 'artifacts']) {
136
+ for (const k of [
137
+ 'agent',
138
+ 'prompt',
139
+ 'cwd',
140
+ 'startedAt',
141
+ 'status',
142
+ 'durationMs',
143
+ 'completedAt',
144
+ 'steps',
145
+ 'artifacts',
146
+ ]) {
137
147
  if (rec[k] !== undefined) {
138
148
  existing[k] = rec[k];
139
149
  }
@@ -178,10 +178,14 @@ export async function backfillThreadTitles(options) {
178
178
  break;
179
179
  }
180
180
  }
181
- catch { /* skip malformed */ }
181
+ catch {
182
+ /* skip malformed */
183
+ }
182
184
  }
183
185
  }
184
- catch { /* if we can't read, fall through and let the append fail */ }
186
+ catch {
187
+ /* if we can't read, fall through and let the append fail */
188
+ }
185
189
  if (userBeatUs) {
186
190
  result.alreadyTitled++;
187
191
  continue;
@@ -39,7 +39,7 @@ function showBudget() {
39
39
  const cfg = loadBudget();
40
40
  const spend = computeSpendToday();
41
41
  newline();
42
- console.log(info('Today\'s estimated spend:'), fmtUsd(spend.totalUsd), `(${spend.sessionCount} session${spend.sessionCount === 1 ? '' : 's'})`);
42
+ console.log(info("Today's estimated spend:"), fmtUsd(spend.totalUsd), `(${spend.sessionCount} session${spend.sessionCount === 1 ? '' : 's'})`);
43
43
  if (cfg.dailyUsd === null) {
44
44
  console.log(info('Daily budget:'), 'none — no enforcement');
45
45
  console.log(info('Set one with:'), 'codehere budget set 5');
@@ -67,7 +67,7 @@ function setBudget(value) {
67
67
  cfg.dailyUsd = n;
68
68
  saveBudget(cfg);
69
69
  console.log(success('Daily budget set to'), fmtUsd(n));
70
- console.log(info('Spawns will be blocked once today\'s estimated spend reaches this amount.'));
70
+ console.log(info("Spawns will be blocked once today's estimated spend reaches this amount."));
71
71
  console.log(info('Disable with:'), 'codehere budget off');
72
72
  }
73
73
  function disableBudget() {
@@ -39,7 +39,7 @@
39
39
  * Refuses to follow symlinks (lstat check) so a hostile/misplaced symlink at
40
40
  * `.codehere/CONTEXT.md` or `.gitignore` cannot redirect writes outside the repo.
41
41
  */
42
- import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, lstatSync, statSync, symlinkSync, readlinkSync, rmSync } from 'fs';
42
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, lstatSync, statSync, symlinkSync, readlinkSync, rmSync, } from 'fs';
43
43
  import { homedir } from 'os';
44
44
  import { join, dirname } from 'path';
45
45
  import { colors, success, info, warning, error, newline } from '../../../ui.js';
@@ -316,7 +316,7 @@ function findSymlinkAncestor(codehereDir, relativePath) {
316
316
  // Defensive: reject any relative path containing .. — even though templates
317
317
  // are hard-coded, this protects against future schema additions slipping a
318
318
  // traversal through.
319
- if (segments.some(seg => seg === '..' || seg === '')) {
319
+ if (segments.some((seg) => seg === '..' || seg === '')) {
320
320
  return relativePath;
321
321
  }
322
322
  // Walk codehereDir, codehereDir/seg1, codehereDir/seg1/seg2, ... (excluding
@@ -338,12 +338,13 @@ function ensureGitignore(repoRoot) {
338
338
  return { result: 'refused-symlink', addedLines: 0 };
339
339
  const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
340
340
  // De-duplicate: only append lines that aren't already present.
341
- const missing = GITIGNORE_LINES.filter(line => !existing.includes(line));
341
+ const missing = GITIGNORE_LINES.filter((line) => !existing.includes(line));
342
342
  if (missing.length === 0)
343
343
  return { result: 'already-present', addedLines: 0 };
344
344
  const block = (existing.length > 0 && !existing.endsWith('\n') ? '\n' : '') +
345
345
  `\n${GITIGNORE_MARKER}\n` +
346
- missing.join('\n') + '\n';
346
+ missing.join('\n') +
347
+ '\n';
347
348
  appendFileSync(gitignorePath, block);
348
349
  return { result: 'updated', addedLines: missing.length };
349
350
  }
@@ -48,7 +48,7 @@ import { buildMemoryPrefix } from '../../../infrastructure/orchestration/memory-
48
48
  * Built lazily from init-command.ts's F3_TEMPLATES so the seed bodies stay
49
49
  * in one place — no duplication, no drift.
50
50
  */
51
- const DEFAULT_SEED_BODIES = new Map(F3_TEMPLATES.map(t => [t.filename, t.body]));
51
+ const DEFAULT_SEED_BODIES = new Map(F3_TEMPLATES.map((t) => [t.filename, t.body]));
52
52
  function lessonIdForText(text) {
53
53
  return createHash('sha256').update(text).digest('hex').slice(0, 16);
54
54
  }
@@ -58,8 +58,8 @@ function readLessonsFile(path) {
58
58
  return readFileSync(path, 'utf8')
59
59
  .trim()
60
60
  .split('\n')
61
- .filter(l => l.length > 0)
62
- .map(l => {
61
+ .filter((l) => l.length > 0)
62
+ .map((l) => {
63
63
  try {
64
64
  return JSON.parse(l);
65
65
  }
@@ -83,7 +83,7 @@ function readLessonsFile(path) {
83
83
  function appendLessonIfNew(path, text, source) {
84
84
  const id = lessonIdForText(text);
85
85
  const existing = readLessonsFile(path);
86
- const dup = existing.find(l => l.id === id);
86
+ const dup = existing.find((l) => l.id === id);
87
87
  if (dup)
88
88
  return { result: 'duplicate', lesson: dup };
89
89
  const lesson = {
@@ -326,7 +326,9 @@ function gatherLayerSummary(codehereDir, projectRoot) {
326
326
  const episodicLearnings = join(codehereDir, 'memory/episodic/AGENT_LEARNINGS.jsonl');
327
327
  const repoMapPath = join(codehereDir, 'memory/episodic/repo_map.json');
328
328
  const learningsLine = existsSync(episodicLearnings) || isSymlink(episodicLearnings)
329
- ? (isSymlink(episodicLearnings) ? `AGENT_LEARNINGS.jsonl → ${readlinkSync(episodicLearnings)}` : 'AGENT_LEARNINGS.jsonl (real file)')
329
+ ? isSymlink(episodicLearnings)
330
+ ? `AGENT_LEARNINGS.jsonl → ${readlinkSync(episodicLearnings)}`
331
+ : 'AGENT_LEARNINGS.jsonl (real file)'
330
332
  : '(no AGENT_LEARNINGS.jsonl)';
331
333
  const repoMapLine = existsSync(repoMapPath)
332
334
  ? `repo_map.json (${fmtSize(statSync(repoMapPath).size)})`
@@ -354,9 +356,12 @@ function gatherLayerSummary(codehereDir, projectRoot) {
354
356
  const skillsDir = join(codehereDir, 'skills');
355
357
  if (existsSync(skillsDir)) {
356
358
  const subdirs = readdirSync(skillsDir, { withFileTypes: true })
357
- .filter(e => e.isDirectory())
358
- .filter(e => existsSync(join(skillsDir, e.name, 'SKILL.md')));
359
- out.push({ layer: 'skills', line: `skills · ${subdirs.length} skill folder${subdirs.length === 1 ? '' : 's'}` });
359
+ .filter((e) => e.isDirectory())
360
+ .filter((e) => existsSync(join(skillsDir, e.name, 'SKILL.md')));
361
+ out.push({
362
+ layer: 'skills',
363
+ line: `skills · ${subdirs.length} skill folder${subdirs.length === 1 ? '' : 's'}`,
364
+ });
360
365
  }
361
366
  else {
362
367
  out.push({ layer: 'skills', line: `skills · (no skills/ dir)` });
@@ -368,7 +373,10 @@ function gatherLayerSummary(codehereDir, projectRoot) {
368
373
  const parsed = JSON.parse(readFileSync(patternsPath, 'utf8'));
369
374
  const builtin = Array.isArray(parsed.patterns_builtin) ? parsed.patterns_builtin.length : 0;
370
375
  const project = Array.isArray(parsed.patterns_project) ? parsed.patterns_project.length : 0;
371
- out.push({ layer: 'protocols', line: `protocols · hook_patterns.json (${builtin} built-in + ${project} project)` });
376
+ out.push({
377
+ layer: 'protocols',
378
+ line: `protocols · hook_patterns.json (${builtin} built-in + ${project} project)`,
379
+ });
372
380
  }
373
381
  catch {
374
382
  out.push({ layer: 'protocols', line: `protocols · hook_patterns.json (parse error)` });
@@ -389,7 +397,10 @@ function gatherLayerSummary(codehereDir, projectRoot) {
389
397
  }
390
398
  else {
391
399
  const truncNote = inj.truncated ? ' TRUNCATED' : '';
392
- out.push({ layer: 'injection', line: `injection · ${inj.tokens} tokens · ${inj.included.join(', ')}${truncNote}` });
400
+ out.push({
401
+ layer: 'injection',
402
+ line: `injection · ${inj.tokens} tokens · ${inj.included.join(', ')}${truncNote}`,
403
+ });
393
404
  }
394
405
  return out;
395
406
  }
@@ -526,7 +537,10 @@ function executeMemoryShow(layerArg) {
526
537
  console.log(` ${info('empty ')} no lessons.jsonl yet`);
527
538
  break;
528
539
  }
529
- const lines = readFileSync(path, 'utf8').trim().split('\n').filter(l => l.length > 0);
540
+ const lines = readFileSync(path, 'utf8')
541
+ .trim()
542
+ .split('\n')
543
+ .filter((l) => l.length > 0);
530
544
  if (lines.length === 0) {
531
545
  console.log(` ${info('empty ')} lessons.jsonl is empty — run ${colors.cyan('codehere memory graduate')} (coming in v0.5)`);
532
546
  break;
@@ -561,8 +575,8 @@ function executeMemoryShow(layerArg) {
561
575
  break;
562
576
  }
563
577
  const subdirs = readdirSync(skillsDir, { withFileTypes: true })
564
- .filter(e => e.isDirectory())
565
- .filter(e => existsSync(join(skillsDir, e.name, 'SKILL.md')));
578
+ .filter((e) => e.isDirectory())
579
+ .filter((e) => existsSync(join(skillsDir, e.name, 'SKILL.md')));
566
580
  if (subdirs.length === 0) {
567
581
  console.log(` ${info('empty ')} no skill folders yet (skills/<name>/SKILL.md)`);
568
582
  break;
@@ -907,8 +921,8 @@ function executeMemoryInit(options) {
907
921
  }
908
922
  // Idempotency hint when nothing was actually migrated.
909
923
  const allOutcomes = [contextOutcome, handoverOutcome, promptsResult.outcome];
910
- const anyMigrated = allOutcomes.some(o => o === 'migrated');
911
- const allAlreadyOrMissing = allOutcomes.every(o => o === 'already-migrated' || o === 'missing');
924
+ const anyMigrated = allOutcomes.some((o) => o === 'migrated');
925
+ const allAlreadyOrMissing = allOutcomes.every((o) => o === 'already-migrated' || o === 'missing');
912
926
  if (!anyMigrated && allAlreadyOrMissing) {
913
927
  newline();
914
928
  console.log(` ${info('no-op')} everything already migrated or no source files to migrate.`);
@@ -122,8 +122,8 @@ export function buildHookPatternsExport() {
122
122
  source: 'agent/src/infrastructure/security/trust-scorer.ts',
123
123
  _note: "Exported from codehere's trust-scorer pattern bank via 'codehere protocols export'. " +
124
124
  "DO NOT hand-edit `patterns_builtin[]` — it's regenerated on every export. " +
125
- "Add project-specific patterns to `patterns_project[]` — those are preserved across re-exports. " +
126
- "Pattern ids are stable slugs of category + description; reference them deterministically.",
125
+ 'Add project-specific patterns to `patterns_project[]` — those are preserved across re-exports. ' +
126
+ 'Pattern ids are stable slugs of category + description; reference them deterministically.',
127
127
  patterns_builtin: builtin,
128
128
  patterns_project: [],
129
129
  };
@@ -107,8 +107,8 @@ function gitListFiles(repoRoot) {
107
107
  return null;
108
108
  return r.stdout
109
109
  .split('\0')
110
- .filter(p => p.length > 0)
111
- .filter(p => SUPPORTED_EXTENSIONS.has(extname(p)));
110
+ .filter((p) => p.length > 0)
111
+ .filter((p) => SUPPORTED_EXTENSIONS.has(extname(p)));
112
112
  }
113
113
  /**
114
114
  * Count references to a symbol name across the repo via git grep.
@@ -191,13 +191,13 @@ export function buildRepoMap(repoRoot) {
191
191
  // acceptable for v0.5 — codehere's own 249-symbol repo runs in ~5s.
192
192
  // v0.6's tree-sitter port will swap raw grep for AST-aware reference
193
193
  // resolution + PageRank, which is faster AND more accurate.
194
- const uniqueNames = Array.from(new Set(allSymbols.map(s => s.name)));
194
+ const uniqueNames = Array.from(new Set(allSymbols.map((s) => s.name)));
195
195
  const refCounts = new Map();
196
196
  for (const name of uniqueNames) {
197
197
  refCounts.set(name, countReferences(repoRoot, name));
198
198
  }
199
199
  const topSymbols = allSymbols
200
- .map(s => ({ ...s, references: refCounts.get(s.name) ?? 0 }))
200
+ .map((s) => ({ ...s, references: refCounts.get(s.name) ?? 0 }))
201
201
  // Stable sort: references DESC, then name ASC for tie-breaking.
202
202
  .sort((a, b) => b.references - a.references || a.name.localeCompare(b.name))
203
203
  .slice(0, 50);
@@ -648,7 +648,24 @@ async function runSubcommand(name, extraArgs) {
648
648
  });
649
649
  });
650
650
  }
651
+ /**
652
+ * UAT-002 — exit-intent words typed at the REPL prompt without a leading
653
+ * slash should leave the session, not get routed to a Conductor spawn.
654
+ *
655
+ * Pre-fix: typing bare `exit` (no `/`) fell through to spawnViaConductor,
656
+ * which booted Conductor, picked claude-code under default routing, made
657
+ * a real API call (network or quota), and emitted a confusing Final Report
658
+ * echoing the provider error verbatim. First-impression UX bug found by
659
+ * UAT 2026-05-19.
660
+ */
661
+ const EXIT_INTENT_WORDS = new Set(['exit', 'quit', 'q', 'bye', 'goodbye', 'logout', ':q']);
651
662
  async function spawnViaConductor(prompt, state) {
663
+ // UAT-002 guard — exit-intent before any spawn / API call.
664
+ const trimmed = prompt.trim().toLowerCase();
665
+ if (EXIT_INTENT_WORDS.has(trimmed)) {
666
+ console.log(colors.dim('Goodbye! 👋'));
667
+ process.exit(0);
668
+ }
652
669
  const installed = availableAgents().filter((a) => a.installed);
653
670
  if (installed.length === 0) {
654
671
  console.log(warning('No specialist installed — install one and try again.'));
@@ -711,7 +728,9 @@ function renderEvent(event) {
711
728
  console.log(colors.dim(' │ ') + icon + ' ' + label);
712
729
  // P22 / Track B #8 — render last 3 lines of step output for done/failed
713
730
  if (lastStep.output && String(lastStep.output).trim()) {
714
- const outLines = String(lastStep.output).split(String.fromCharCode(10)).filter(l => l.trim());
731
+ const outLines = String(lastStep.output)
732
+ .split(String.fromCharCode(10))
733
+ .filter((l) => l.trim());
715
734
  const preview = outLines.slice(-3);
716
735
  for (const ln of preview) {
717
736
  console.log(colors.dim(' │ ') + colors.gray(ln));
package/dist/ui.js CHANGED
@@ -397,37 +397,45 @@ export function randomFarewell() {
397
397
  export function randomCleanScan() {
398
398
  return CLEAN_SCAN_CELEBRATIONS[Math.floor(Math.random() * CLEAN_SCAN_CELEBRATIONS.length)];
399
399
  }
400
+ /**
401
+ * v0.6.2 sync — every non-replOnly entry MUST correspond to a real
402
+ * `program.command('<name>')` registration in src/index.ts. Drift is
403
+ * blocked by test/cli/slash-commands-sync.test.ts (UAT-001 CI guard).
404
+ *
405
+ * Pre-fix (v0.6.1 and earlier): this list carried 16 ghost commands
406
+ * (ask/explain/fix/review/plan/suggest/test/docs/refactor/orchestrate/run/
407
+ * git-review/index/cost/models/undo) removed in v0.3 — and was missing
408
+ * 8 real commands (init/memory/permission/narrator/budget/start/task/kill).
409
+ * Catastrophic first-impression bug found by UAT 2026-05-19.
410
+ */
400
411
  export const SLASH_COMMANDS = [
401
- // AI-powered
402
- { name: 'ask', args: '<question>', description: 'Ask anything about your codebase' },
403
- { name: 'explain', args: '<file>', description: 'Explain a file' },
404
- { name: 'fix', args: '<file> <hint>', description: 'Apply a targeted fix' },
405
- { name: 'review', args: '<file>', description: 'Code review with findings' },
406
- { name: 'plan', args: '<goal>', description: 'Break a goal into steps' },
407
- { name: 'suggest', args: '<query>', description: 'Read-only assistant — no changes' },
408
- { name: 'test', args: '<file>', description: 'Generate tests for a file' },
409
- { name: 'docs', args: '<type>', description: 'Generate documentation' },
410
- { name: 'refactor', args: '<pattern> <instr>', description: 'Refactor across files' },
411
- { name: 'orchestrate', args: '<goal>', description: 'Multi-agent workflow' },
412
- { name: 'run', args: '<goal>', description: 'Execute a task or goal' },
413
- { name: 'git-review', args: '', description: 'Review git changes' },
414
- // Safety & config (no key)
415
- { name: 'trust', args: '[path]', description: 'Scan for vulnerabilities', noKey: true },
416
- { name: 'report', args: '[path]', description: 'Security audit report', noKey: true },
417
- { name: 'hook', args: '[install|status]', description: 'Safety hook management', noKey: true },
418
- { name: 'status', args: '', description: 'Dashboard — hook activity', noKey: true },
419
- { name: 'health', args: '', description: 'System health check', noKey: true },
420
- { name: 'index', args: '', description: 'Build the search index' },
421
- { name: 'serve', args: '', description: 'Start MCP server', noKey: true },
422
- { name: 'setup', args: '', description: 'Configure API provider', noKey: true },
412
+ // Setup + config (no key)
413
+ { name: 'setup', args: '', description: 'Interactive first-run wizard', noKey: true },
414
+ { name: 'init', args: '', description: 'Scaffold .codehere/ in this repo', noKey: true },
415
+ { name: 'memory', args: '<sub>', description: 'Project memory · init / repo-map / show', noKey: true },
423
416
  { name: 'config', args: '[key] [value]', description: 'Manage preferences', noKey: true },
424
- { name: 'cost', args: '', description: 'View API cost tracking', noKey: true },
425
- { name: 'models', args: '', description: 'List available AI models', noKey: true },
426
- { name: 'undo', args: '<file>', description: 'Undo file changes', noKey: true },
427
- // Utility
417
+ { name: 'permission', args: '<sub>', description: 'Manage .codehere/permissions.yaml allow/deny', noKey: true },
418
+ { name: 'narrator', args: '', description: 'Customize the PM narrator prompt', noKey: true },
419
+ { name: 'budget', args: '[set <amount>]', description: 'Daily spend budget', noKey: true },
420
+ // Safety + observability (no key)
421
+ { name: 'hook', args: '[install|status]', description: 'Safety hook management', noKey: true },
422
+ { name: 'trust', args: '[path]', description: 'Scan for vulnerabilities', noKey: true },
423
+ { name: 'report', args: '[path]', description: 'Shareable security audit', noKey: true },
424
+ { name: 'status', args: '', description: 'Security dashboard — hook activity', noKey: true },
425
+ { name: 'health', args: '', description: 'System health + metrics', noKey: true },
426
+ // Orchestration
427
+ { name: 'start', args: '', description: 'Open the dashboard at localhost:3773' },
428
+ { name: 'serve', args: '', description: 'Run headless MCP server (no UI)', noKey: true },
429
+ { name: 'task', args: '<prompt>', description: 'Delegate a task to a coding agent' },
430
+ { name: 'kill', args: '<sessionId>', description: 'Kill a running task' },
431
+ // Utility (no key)
428
432
  { name: 'help', args: '', description: 'Command reference', noKey: true },
429
- { name: 'clear', args: '', description: 'Clear the screen', noKey: true },
430
- { name: 'exit', args: '', description: 'Leave the session', noKey: true },
433
+ // REPL-only meta-commands (handled in repl.ts, not in Commander).
434
+ // Marked replOnly so the CI sync guard skips them.
435
+ { name: 'agents', args: '', description: 'List installed specialists', noKey: true, replOnly: true },
436
+ { name: 'history', args: '', description: 'Recent REPL session history', noKey: true, replOnly: true },
437
+ { name: 'clear', args: '', description: 'Clear the screen', noKey: true, replOnly: true },
438
+ { name: 'exit', args: '', description: 'Leave the session', noKey: true, replOnly: true },
431
439
  ];
432
440
  export function showSlashCommandMenu() {
433
441
  newline();
@@ -64,7 +64,7 @@ function checkTypoSingle(token, knownCommands) {
64
64
  .map((n) => ({ n, d: levenshtein(lower, n) }))
65
65
  .filter((s) => s.d > 0 && s.d <= 2 && s.d < lower.length)
66
66
  // Deterministic tie-breaker (alphabetical) so ranking is stable across runs.
67
- .sort((a, b) => (a.d - b.d) || a.n.localeCompare(b.n))
67
+ .sort((a, b) => a.d - b.d || a.n.localeCompare(b.n))
68
68
  .slice(0, 3);
69
69
  return scored.length > 0 ? scored.map((s) => s.n) : null;
70
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.6.1",
3
+ "version": "0.6.2",
4
4
  "description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, OpenCode, Gemini, and any user-configured CLI agent from one local web UI — every AI action traced, every file scored, every spawn warmed with project memory.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",