codehere 0.3.0-rc.12 → 0.3.0-rc.27

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.
@@ -9,7 +9,7 @@ import { join, dirname, resolve } from 'path';
9
9
  */
10
10
  export function findCodebaseRoot(startPath = process.cwd()) {
11
11
  const resolvedStart = resolve(startPath);
12
- let currentDir = resolvedStart;
12
+ const currentDir = resolvedStart;
13
13
  // Try to find git root first
14
14
  const gitRoot = findGitRoot(currentDir);
15
15
  if (gitRoot) {
package/dist/index.js CHANGED
@@ -37,12 +37,19 @@ catch {
37
37
  }
38
38
  config();
39
39
  import { getVersion } from './utils/version.js';
40
+ import { checkTypo } from './utils/typo-guard.js';
40
41
  const program = new Command();
41
42
  program
42
43
  .name('codehere')
43
44
  .description('A PM for your AI coding agents. Delegate a task, watch it run, get a plain-English report with a trust score on every file.')
44
45
  .version(getVersion())
45
- .configureHelp({ helpWidth: 80, sortSubcommands: true });
46
+ .configureHelp({ helpWidth: 80, sortSubcommands: true })
47
+ // rc.18: many users hit `codehere --help` first and get Commander's flat
48
+ // list. The grouped `codehere help` is more useful for discovery. Surface
49
+ // it inline so muscle-memory `--help` users find the better page.
50
+ // (Pin: no raw ANSI in --help output — existing commands.test.ts asserts
51
+ // this, see test/cli/commands.test.ts "no raw ANSI escape codes in --help".)
52
+ .addHelpText('after', '\nTip: `codehere help` shows commands grouped by use case (Delegate · Safety & Trust · Configuration).');
46
53
  // Default action · show welcome, then drop into the interactive REPL on TTY.
47
54
  // codehere offers TWO surfaces, both first-class: the CLI REPL (here) and the
48
55
  // web UI (`codehere start`). Users pick whichever fits their workflow.
@@ -52,8 +59,23 @@ program
52
59
  // One-shot delegate: `codehere "review src/auth.ts"` runs that task and exits.
53
60
  // Useful for scripting or quick one-offs without entering the REPL.
54
61
  if (query && query.trim()) {
62
+ const trimmed = query.trim();
63
+ // Typo guard — see src/utils/typo-guard.ts for heuristics.
64
+ // Returns suggestions when input looks like a typo; null falls through.
65
+ const known = program.commands.map((c) => c.name());
66
+ const suggestions = checkTypo(trimmed, known);
67
+ if (suggestions) {
68
+ console.error(`codehere: unknown command '${trimmed}'.`);
69
+ console.error('');
70
+ console.error(' Did you mean:');
71
+ for (const s of suggestions)
72
+ console.error(` codehere ${s}`);
73
+ console.error('');
74
+ console.error(` If you meant to ask a question, quote it: codehere "${trimmed}"`);
75
+ process.exit(1);
76
+ }
55
77
  const { runOneShot } = await import('./presentation/cli/repl.js');
56
- await runOneShot(query.trim());
78
+ await runOneShot(trimmed);
57
79
  return;
58
80
  }
59
81
  if (!process.stdin.isTTY) {
@@ -103,8 +125,23 @@ program
103
125
  .command('health')
104
126
  .description('Check system health and metrics')
105
127
  .option('-m, --metrics', 'Show detailed metrics')
128
+ .option('--json', 'Machine-readable JSON (for monitoring/CI)')
106
129
  .action((options) => {
107
130
  const health = performHealthCheck();
131
+ if (options.json) {
132
+ // Machine-readable: status + checks (+ metrics when --metrics).
133
+ // Stdout is the contract — never mix with the human banner.
134
+ // Use process.exitCode (not process.exit) so a piped consumer sees the
135
+ // full JSON before the process tears down (rc.16: Codex flagged the old
136
+ // process.exit call as a truncation hazard under stdout redirection).
137
+ const payload = { status: health.status, checks: health.checks };
138
+ if (options.metrics)
139
+ payload.metrics = getMetrics();
140
+ console.log(JSON.stringify(payload, null, 2));
141
+ if (health.status === 'unhealthy')
142
+ process.exitCode = 1;
143
+ return;
144
+ }
108
145
  if (isMinimalStyle()) {
109
146
  printMinimalHeader('health', '');
110
147
  }
@@ -202,9 +239,9 @@ program
202
239
  .command('report')
203
240
  .description('Generate a shareable security audit report — narrative, not just a score')
204
241
  .argument('[target]', 'File or directory to audit (default: current directory)')
205
- .option('--out <file>', 'Write report to a specific file (default: codehere-audit.md)')
206
- .option('--stdout', 'Print the report to stdout instead of writing a file')
207
- .option('--json', 'Output machine-readable JSON instead of markdown')
242
+ .option('--out <file>', 'Write report to a specific file (default: codehere-audit.md for markdown)')
243
+ .option('--stdout', 'Print the report to stdout (already the default for --json)')
244
+ .option('--json', 'Output machine-readable JSON to stdout (pipe to jq, etc.)')
208
245
  .option('--simple', 'Plain English output — no jargon, for non-technical stakeholders')
209
246
  .addHelpText('after', `
210
247
  Examples:
@@ -76,17 +76,26 @@ export function detectAmplificationAlerts(sessions) {
76
76
  continue;
77
77
  let message = '';
78
78
  const risk = session.amplificationRisk;
79
- if (session.blockedCalls > 0 && session.totalCalls > 20) {
80
- message = `${session.totalCalls} tool calls with ${session.blockedCalls} blocked — possible injection in multi-agent chain`;
79
+ // rc.17 framing: don't accuse "injection" by default — that wording scares
80
+ // users on perfectly normal long attended sessions. Reserve the strong
81
+ // language for cases where the *blocked-call ratio* is high (real signal
82
+ // of prompts trying things they shouldn't), not just call volume.
83
+ // UX_OBSERVATIONS.md P1.
84
+ const blockRatio = session.totalCalls > 0 ? session.blockedCalls / session.totalCalls : 0;
85
+ if (session.blockedCalls > 0 && session.totalCalls > 20 && blockRatio > 0.05) {
86
+ message = `${session.totalCalls} tool calls, ${session.blockedCalls} blocked (${(blockRatio * 100).toFixed(0)}%) — high block ratio, review for injection`;
87
+ }
88
+ else if (session.blockedCalls > 0 && session.totalCalls > 20) {
89
+ message = `${session.totalCalls} tool calls, ${session.blockedCalls} blocked — long session, review if unattended`;
81
90
  }
82
91
  else if (session.totalCalls > 50) {
83
- message = `${session.totalCalls} tool calls in one session — check for amplification`;
92
+ message = `${session.totalCalls} tool calls in one session — high density, review if unattended`;
84
93
  }
85
94
  else if ((session.toolMix['Bash'] ?? 0) > 10) {
86
- message = `${session.toolMix['Bash']} Bash calls in one session — elevated risk`;
95
+ message = `${session.toolMix['Bash']} Bash calls in one session — elevated tool-mix`;
87
96
  }
88
97
  else {
89
- message = `${session.totalCalls} calls, ${session.blockedCalls} blocked — worth reviewing`;
98
+ message = `${session.totalCalls} calls, ${session.blockedCalls} blocked — worth a glance`;
90
99
  }
91
100
  alerts.push({
92
101
  sessionId: session.sessionId,
@@ -91,7 +91,12 @@ function tokenize(prompt) {
91
91
  // "add tests" "add-tests" "add_tests" "add/tests" "add:tests" "add.tests"
92
92
  // Note: must NOT collapse the leading/trailing spaces — they're load-bearing
93
93
  // for the boundary check below (`.includes(' ' + t + ' ')`).
94
- const normalized = ' ' + prompt.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim() + ' ';
94
+ const normalized = ' ' +
95
+ prompt
96
+ .toLowerCase()
97
+ .replace(/[^a-z0-9]+/g, ' ')
98
+ .trim() +
99
+ ' ';
95
100
  return { tokens, normalized };
96
101
  }
97
102
  /**
@@ -240,7 +245,6 @@ function spawnConductor(prompt, options, lookup) {
240
245
  },
241
246
  };
242
247
  buffer.push(errEvent);
243
- queueMicrotask(() => emitter.emit('event', errEvent));
244
248
  return synthesizeSession({ sessionId, startedAt, prompt, statusValue, emitter, buffer });
245
249
  }
246
250
  // Emit the routing-decision event FIRST so the audit log records WHY this
@@ -255,8 +259,11 @@ function spawnConductor(prompt, options, lookup) {
255
259
  promptExcerpt: prompt.slice(0, 200),
256
260
  },
257
261
  };
262
+ // Buffer replay on subscribe() handles delivery to the first subscriber. Do
263
+ // NOT also queueMicrotask(emit) — the microtask fires AFTER subscribe attaches
264
+ // its handler, so the handler would receive routingEvent twice (rc.13 bug:
265
+ // duplicate "routing → ..." line in the REPL).
258
266
  buffer.push(routingEvent);
259
- queueMicrotask(() => emitter.emit('event', routingEvent));
260
267
  // Find the chosen specialist adapter and spawn against it.
261
268
  const specialist = lookup().find((a) => a.id === decision.agentId);
262
269
  if (!specialist) {
@@ -269,7 +276,6 @@ function spawnConductor(prompt, options, lookup) {
269
276
  data: { message: `Conductor picked '${decision.agentId}' but no adapter with that ID is registered.` },
270
277
  };
271
278
  buffer.push(errEvent);
272
- queueMicrotask(() => emitter.emit('event', errEvent));
273
279
  return synthesizeSession({ sessionId, startedAt, prompt, statusValue, emitter, buffer });
274
280
  }
275
281
  // Spawn the specialist session and proxy every event through.
@@ -284,7 +290,12 @@ function spawnConductor(prompt, options, lookup) {
284
290
  stepsSnapshot = specialistSession.steps;
285
291
  if (specialistSession.artifacts)
286
292
  artifactsSnapshot = specialistSession.artifacts;
287
- if (evt.type === 'codehere:complete' || evt.type === 'codehere:error') {
293
+ // Specialist adapters terminate with an 'exit' event — never emit
294
+ // 'codehere:complete' / 'codehere:error'. Without mirroring on 'exit',
295
+ // session.status stayed 'running' forever and the REPL printed
296
+ // "✗ └─ running" (rc.13 bug). Also keep the legacy types for safety in
297
+ // case a future adapter emits them.
298
+ if (evt.type === 'exit' || evt.type === 'codehere:complete' || evt.type === 'codehere:error') {
288
299
  statusValue = specialistSession.status;
289
300
  }
290
301
  });
@@ -478,7 +478,6 @@ export function compileSafePattern(source, fallback, agentId) {
478
478
  // Layer 1 · static shape check — this is the only fully reliable defense.
479
479
  const danger = detectRedosShape(source);
480
480
  if (danger) {
481
- // eslint-disable-next-line no-console
482
481
  console.warn(`[codehere] Regex for "${agentId}" rejected (${danger}) — using fallback`);
483
482
  return fallback;
484
483
  }
@@ -487,7 +486,6 @@ export function compileSafePattern(source, fallback, agentId) {
487
486
  candidate = new RegExp(source);
488
487
  }
489
488
  catch {
490
- // eslint-disable-next-line no-console
491
489
  console.warn(`[codehere] Invalid regex in agents.yaml for "${agentId}" — using fallback`);
492
490
  return fallback;
493
491
  }
@@ -496,7 +494,6 @@ export function compileSafePattern(source, fallback, agentId) {
496
494
  const start = Date.now();
497
495
  candidate.test('a'.repeat(1000));
498
496
  if (Date.now() - start > 100) {
499
- // eslint-disable-next-line no-console
500
497
  console.warn(`[codehere] Regex for "${agentId}" too slow (likely ReDoS) — using fallback`);
501
498
  return fallback;
502
499
  }
@@ -20,7 +20,8 @@
20
20
  */
21
21
  import http from 'http';
22
22
  import { readFileSync, existsSync, statSync, watchFile, unwatchFile } from 'fs';
23
- import { join, relative } from 'path';
23
+ import { join, relative, dirname } from 'path';
24
+ import { fileURLToPath } from 'url';
24
25
  import { collectFilesForMCP, scoreFileForMCP } from './mcp-helpers.js';
25
26
  import { scanPayload, calculateRiskScore, AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
26
27
  import { getVersion } from '../../utils/version.js';
@@ -589,8 +590,14 @@ export function startMCPServer(options = {}) {
589
590
  res.end(JSON.stringify({ error: 'Method not allowed for this path' }));
590
591
  return;
591
592
  }
592
- // MCP endpoint
593
- if (req.method === 'POST' && req.url === '/mcp') {
593
+ // MCP endpoint — POST jsonrpc; reject other verbs with 405 + Allow header
594
+ // (rc.17: was 404, which is wrong HTTP semantic — UX_OBSERVATIONS P3).
595
+ if (req.url === '/mcp') {
596
+ if (req.method !== 'POST') {
597
+ res.writeHead(405, { 'Content-Type': 'application/json', Allow: 'POST' });
598
+ res.end(JSON.stringify({ error: 'MCP endpoint accepts POST only', method: req.method }));
599
+ return;
600
+ }
594
601
  let body = '';
595
602
  req.on('data', (chunk) => {
596
603
  body += chunk;
@@ -612,6 +619,29 @@ export function startMCPServer(options = {}) {
612
619
  });
613
620
  return;
614
621
  }
622
+ // /api/version — small completeness item for ops monitoring (rc.17).
623
+ // ESM-friendly: resolve package.json relative to the compiled module's
624
+ // own URL via fileURLToPath. Works in both src/ (during dev) and dist/
625
+ // because the relative path up to package.json is the same in both.
626
+ if (req.url === '/api/version' && req.method === 'GET') {
627
+ try {
628
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
629
+ const pkgPath = join(moduleDir, '..', '..', '..', 'package.json');
630
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
631
+ res.writeHead(200, { 'Content-Type': 'application/json' });
632
+ res.end(JSON.stringify({ version: pkg.version, name: pkg.name }));
633
+ }
634
+ catch (e) {
635
+ // rc.19: don't leak fs paths to the public HTTP surface.
636
+ // rc.20: but DO log the actual error context for the operator (Codex
637
+ // rc.19 review caught: previous version dropped `e` and logged only a
638
+ // static string, costing diagnosability).
639
+ console.error('[/api/version] failed to read package metadata:', e);
640
+ res.writeHead(500, { 'Content-Type': 'application/json' });
641
+ res.end(JSON.stringify({ error: 'Failed to read package metadata' }));
642
+ }
643
+ return;
644
+ }
615
645
  // 404
616
646
  res.writeHead(404);
617
647
  res.end();
@@ -62,6 +62,9 @@ export const IGNORE_DIRS = new Set([
62
62
  '.cache',
63
63
  '.codehere',
64
64
  '.codehere-cache',
65
+ // Cloned reference repos used for dogfooding / comparison — not the user's code.
66
+ // Codehere convention; mirrors how `.git` is hidden tooling-data, not source.
67
+ '.oss-repos',
65
68
  // Test fixtures / mocks (intentionally-vulnerable code used as scanner inputs)
66
69
  'fixtures',
67
70
  'test-fixtures',
@@ -237,14 +237,61 @@ function generateSimpleReport(report, target) {
237
237
  lines.push('Generated by codehere — know what your AI agent did before your tech lead asks.');
238
238
  return lines.join('\n');
239
239
  }
240
+ /**
241
+ * Write report to the requested path. If the path is unwritable (EROFS,
242
+ * EACCES, EPERM, ENOENT on a parent dir we can't create), fall back to
243
+ * stdout so the user still gets the report instead of a stack trace.
244
+ *
245
+ * Returns true if the file was written, false if we fell back to stdout.
246
+ *
247
+ * Provoked by rc.25 UAT: `codehere report --simple` from `/` crashed with
248
+ * `EROFS: read-only file system, open 'codehere-audit.md'`. A user running
249
+ * the command from any unwritable cwd should still get their report.
250
+ */
251
+ function writeReportWithFallback(targetPath, content, label) {
252
+ try {
253
+ writeFileSync(targetPath, content);
254
+ console.log(success(`${label} written to ${targetPath}`));
255
+ return true;
256
+ }
257
+ catch (e) {
258
+ const code = e.code ?? '';
259
+ if (['EROFS', 'EACCES', 'EPERM', 'ENOENT'].includes(code)) {
260
+ console.error(error(`Could not write to ${targetPath} (${code}). Printing to stdout instead — pass --out <path> to a writable location.`));
261
+ process.stdout.write(content + '\n');
262
+ return false;
263
+ }
264
+ throw e;
265
+ }
266
+ }
240
267
  export async function executeReportCommand(target, options = {}) {
241
268
  const cwd = process.cwd();
242
269
  const resolved = target ? (isAbsolute(target) ? target : join(cwd, target)) : cwd;
243
- // Default: write to a file so the user gets a shareable artifact instead of
244
- // raw markdown sprayed at the terminal. --stdout opts into the old behavior
245
- // for piping; --out overrides the default path.
246
- const defaultOutName = options.json ? 'codehere-audit.json' : 'codehere-audit.md';
247
- const effectiveOut = options.stdout ? undefined : (options.out ?? defaultOutName);
270
+ // rc.18: explicit mutex on combinations Codex flagged as silently
271
+ // ambiguous. Two pairs:
272
+ // format {--json, --simple} — pick at most one output format
273
+ // dest {--stdout, --out} — pick at most one destination
274
+ // Previously the second flag silently lost; now we error so users know.
275
+ if (options.json && options.simple) {
276
+ console.error(error('--json and --simple cannot be used together. Pick one output format.'));
277
+ process.exit(2);
278
+ }
279
+ if (options.stdout && options.out) {
280
+ console.error(error('--stdout and --out cannot be used together. Pick one destination.'));
281
+ process.exit(2);
282
+ }
283
+ // Default routing rules (after mutex check):
284
+ // --json → stdout (pipeable to jq, every CLI convention).
285
+ // --simple / md → file (user expects a shareable artifact, not a wall of markdown).
286
+ // --out <path> → wins over the default (explicit beats default).
287
+ // --stdout → forces stdout for non-json formats.
288
+ // Rationale: rc.15 — `report --json` writing to disk silently broke pipelines
289
+ // (`codehere report --json | jq` produced empty stdout). Stdout for machine-
290
+ // readable formats matches `kubectl get -o json`, `docker inspect`, etc.
291
+ const defaultOut = options.json
292
+ ? undefined // stdout
293
+ : 'codehere-audit.md';
294
+ const effectiveOut = options.stdout ? undefined : (options.out ?? defaultOut);
248
295
  if (!existsSync(resolved)) {
249
296
  console.error(error(`Not found: ${resolved}`));
250
297
  process.exit(1);
@@ -297,8 +344,7 @@ export async function executeReportCommand(target, options = {}) {
297
344
  if (options.json) {
298
345
  const output = JSON.stringify(report, null, 2);
299
346
  if (effectiveOut) {
300
- writeFileSync(effectiveOut, output);
301
- console.log(success(`Report written to ${effectiveOut}`));
347
+ writeReportWithFallback(effectiveOut, output, 'Report');
302
348
  }
303
349
  else {
304
350
  console.log(output);
@@ -309,8 +355,7 @@ export async function executeReportCommand(target, options = {}) {
309
355
  if (options.simple) {
310
356
  const simple = generateSimpleReport(report, resolved);
311
357
  if (effectiveOut) {
312
- writeFileSync(effectiveOut, simple);
313
- console.log(success(`Plain-English report written to ${effectiveOut}`));
358
+ writeReportWithFallback(effectiveOut, simple, 'Plain-English report');
314
359
  }
315
360
  else {
316
361
  process.stdout.write(simple + '\n');
@@ -320,9 +365,9 @@ export async function executeReportCommand(target, options = {}) {
320
365
  // Markdown output
321
366
  const markdown = generateMarkdown(report, resolved, cwd);
322
367
  if (effectiveOut) {
323
- writeFileSync(effectiveOut, markdown);
368
+ if (!writeReportWithFallback(effectiveOut, markdown, 'Report'))
369
+ return;
324
370
  newline();
325
- console.log(success(`Report written to ${effectiveOut}`));
326
371
  console.log(colors.dim(` ${paths.length} files · score ${overallScore}/100 · grade ${grade}`));
327
372
  if (criticalCount > 0) {
328
373
  console.log(colors.red(` ${criticalCount} critical issue${criticalCount !== 1 ? 's' : ''} — check the report`));
@@ -148,9 +148,14 @@ function renderSessionDrillDown(sessionId, entries) {
148
148
  console.log(` ${icon} ${time} ${tool} ${colors.dim(reason)}`);
149
149
  }
150
150
  newline();
151
- // Verdict
152
- if (blocked.length > 2 && sorted.length > 20) {
153
- console.log(colors.red(' ⚠ High block rate in a long session — possible prompt injection in multi-agent chain.'));
151
+ // Verdict — rc.17: tighten "injection" claim to actual high block ratio
152
+ // (UX_OBSERVATIONS.md P1; long attended sessions shouldn't get accused).
153
+ if (blocked.length > 2 && sorted.length > 20 && blocked.length / sorted.length > 0.05) {
154
+ const pct = ((blocked.length / sorted.length) * 100).toFixed(0);
155
+ console.log(colors.red(` ⚠ ${pct}% block rate (${blocked.length}/${sorted.length}) — review for injection patterns.`));
156
+ }
157
+ else if (blocked.length > 0 && sorted.length > 20) {
158
+ console.log(colors.yellow(` ${blocked.length} blocked / ${sorted.length} total — long session, review if unattended.`));
154
159
  }
155
160
  else if (blocked.length > 0) {
156
161
  console.log(colors.yellow(` ${blocked.length} blocked call${blocked.length !== 1 ? 's' : ''} — review the blocked entries above.`));
@@ -413,6 +418,13 @@ export async function executeStatusCommand(options = {}) {
413
418
  const cwd = process.cwd();
414
419
  const version = getVersion();
415
420
  const entries = readAuditLog();
421
+ // Validate --since up front — applies to ALL modes, not just --export.
422
+ // (rc.15 fix: previously the validation ran only inside the --export branch,
423
+ // so `codehere status --since bogus` silently succeeded with exit 0.)
424
+ if (options.since && parseDurationToHours(options.since) == null) {
425
+ console.error(`Invalid --since value: "${options.since}". Use e.g. 24h, 7d, 4w.`);
426
+ process.exit(1);
427
+ }
416
428
  // Session drill-down mode
417
429
  if (options.session) {
418
430
  renderSessionDrillDown(options.session, entries);
@@ -421,10 +433,6 @@ export async function executeStatusCommand(options = {}) {
421
433
  // Audit export mode — dump raw events for archival/compliance/sharing
422
434
  if (options.export) {
423
435
  const sinceHours = options.since ? parseDurationToHours(options.since) : null;
424
- if (options.since && sinceHours == null) {
425
- console.error(`Invalid --since value: "${options.since}". Use e.g. 24h, 7d, 4w.`);
426
- process.exit(1);
427
- }
428
436
  try {
429
437
  exportAudit(entries, options.export, sinceHours);
430
438
  }
@@ -480,9 +488,17 @@ export async function executeStatusCommand(options = {}) {
480
488
  }));
481
489
  result.taskCount = tasks.length;
482
490
  }
483
- catch {
491
+ catch (e) {
492
+ // rc.25: JSON consumers reading only stdout were still being fed a
493
+ // lie ([] tasks, 0 count) on real failure (Codex final review:
494
+ // "degraded behavior being presented as success"). Now we surface a
495
+ // structured taskLoadError + status:'degraded' so automation can
496
+ // distinguish "no tasks" from "load failed."
497
+ console.error('[status --tasks --json] task repository failed to load:', e);
484
498
  result.tasks = [];
485
499
  result.taskCount = 0;
500
+ result.taskLoadError = e instanceof Error ? e.message : String(e);
501
+ result.status = 'degraded';
486
502
  }
487
503
  }
488
504
  console.log(JSON.stringify(result, null, 2));
@@ -630,8 +646,14 @@ export async function executeStatusCommand(options = {}) {
630
646
  }
631
647
  }
632
648
  }
633
- catch {
634
- console.log(colors.dim('No tasks found. Run "codehere start" to open the task intake, or "codehere task <prompt>" from the CLI.'));
649
+ catch (e) {
650
+ // rc.25: don't print "No tasks found" — that's a lie when load failed
651
+ // (Codex final review: "degraded behavior presented as success"). Show
652
+ // a clear error line; stderr already has the cause for the operator.
653
+ console.error('[status] task repository failed to load:', e);
654
+ const msg = e instanceof Error ? e.message : String(e);
655
+ console.log(colors.red(` ✗ Could not load task repository: ${msg}`));
656
+ console.log(colors.dim(' See logs above for details. Try `codehere start` to (re)init the task intake.'));
635
657
  }
636
658
  }
637
659
  // Quick trust scan (opt-in)
@@ -22,9 +22,10 @@ import { join, basename } from 'path';
22
22
  import { spawn as childSpawn } from 'child_process';
23
23
  import { fileURLToPath } from 'url';
24
24
  import { dirname } from 'path';
25
- import { colors, success, error, warning, newline, createCodehereLogo, } from '../../ui.js';
25
+ import { colors, success, error, warning, newline, createCodehereLogo } from '../../ui.js';
26
26
  import { getVersion } from '../../utils/version.js';
27
27
  import { SessionManager, availableAgents } from '../../infrastructure/orchestration/session-manager.js';
28
+ import { collectCodeFiles } from '../../infrastructure/security/file-collection.js';
28
29
  import chalk from 'chalk';
29
30
  const brandBlue = chalk.rgb(74, 144, 226);
30
31
  const stdin = process.stdin;
@@ -86,13 +87,55 @@ const SLASH_COMMANDS = [
86
87
  promptTemplate: (a) => `refactor: ${a}`,
87
88
  },
88
89
  // Subcommands · run an existing CLI subcommand inline
89
- { name: 'trust', args: '[path]', description: 'Scan for vulnerabilities', kind: 'sub', noKey: true, subcommand: 'trust' },
90
- { name: 'report', args: '[path]', description: 'Security audit report', kind: 'sub', noKey: true, subcommand: 'report' },
91
- { name: 'hook', args: '[install|status]', description: 'Safety hook management', kind: 'sub', noKey: true, subcommand: 'hook' },
92
- { name: 'status', args: '', description: 'Dashboard — hook activity', kind: 'sub', noKey: true, subcommand: 'status' },
90
+ {
91
+ name: 'trust',
92
+ args: '[path]',
93
+ description: 'Scan for vulnerabilities',
94
+ kind: 'sub',
95
+ noKey: true,
96
+ subcommand: 'trust',
97
+ },
98
+ {
99
+ name: 'report',
100
+ args: '[path]',
101
+ description: 'Security audit report',
102
+ kind: 'sub',
103
+ noKey: true,
104
+ subcommand: 'report',
105
+ },
106
+ {
107
+ name: 'hook',
108
+ args: '[install|status]',
109
+ description: 'Safety hook management',
110
+ kind: 'sub',
111
+ noKey: true,
112
+ subcommand: 'hook',
113
+ },
114
+ {
115
+ name: 'status',
116
+ args: '',
117
+ description: 'Dashboard — hook activity',
118
+ kind: 'sub',
119
+ noKey: true,
120
+ subcommand: 'status',
121
+ },
93
122
  { name: 'health', args: '', description: 'System health check', kind: 'sub', noKey: true, subcommand: 'health' },
94
- { name: 'setup', args: '', description: 'Configure first-run preferences', kind: 'sub', noKey: true, subcommand: 'setup' },
95
- { name: 'config', args: '[key] [value]', description: 'Manage preferences', kind: 'sub', noKey: true, subcommand: 'config' },
123
+ {
124
+ name: 'setup',
125
+ args: '',
126
+ description: 'Configure first-run preferences',
127
+ kind: 'sub',
128
+ noKey: true,
129
+ subcommand: 'setup',
130
+ },
131
+ {
132
+ name: 'config',
133
+ args: '[key] [value]',
134
+ description: 'Manage preferences',
135
+ kind: 'sub',
136
+ noKey: true,
137
+ subcommand: 'config',
138
+ },
96
139
  { name: 'agents', args: '', description: 'List installed specialists', kind: 'session', noKey: true },
97
140
  { name: 'undo', args: '<file>', description: 'Undo file changes (queued for v0.5)', kind: 'session', noKey: true },
98
141
  { name: 'history', args: '', description: 'Show command history', kind: 'session', noKey: true },
@@ -126,6 +169,7 @@ function detectState() {
126
169
  const hasAnyAgent = availableAgents().some((a) => a.installed && a.id !== 'conductor');
127
170
  const isGitRepo = existsSync(join(cwd, '.git'));
128
171
  let fileCount = 0;
172
+ let scannableCount = 0;
129
173
  try {
130
174
  const count = (dir, d) => {
131
175
  if (d > 3)
@@ -142,33 +186,79 @@ function detectState() {
142
186
  return n;
143
187
  };
144
188
  fileCount = count(cwd, 0);
189
+ // rc.21: surface the scannable count — code files that trust/scan
190
+ // would actually look at. Honest count beats a single inflated number.
191
+ // UX_OBSERVATIONS P3. Static ESM import (rc.20 used `require()` which is
192
+ // undefined in this package's ESM runtime — Codex caught the silent fail).
193
+ try {
194
+ scannableCount = collectCodeFiles(cwd).length;
195
+ }
196
+ catch {
197
+ /* ignore — banner stays silent rather than crashes */
198
+ }
145
199
  }
146
200
  catch {
147
201
  /* ignore */
148
202
  }
149
- return { name, hasHooks, hasAgents: hasAnyAgent, isGitRepo, fileCount };
203
+ return { name, hasHooks, hasAgents: hasAnyAgent, isGitRepo, fileCount, scannableCount };
150
204
  }
151
- function printWelcome() {
152
- const state = detectState();
205
+ /**
206
+ * Format the banner's "X files (Y scannable)" line.
207
+ *
208
+ * Pure function (no I/O) so the rc.20 silent-fail regression that Codex
209
+ * caught — banner falling back to total-only when scannable load fails —
210
+ * has a tight unit test.
211
+ *
212
+ * Contract:
213
+ * - scannable > 0 AND scannable < total → "<total> files (<scannable> scannable)"
214
+ * - otherwise → "<total> files"
215
+ *
216
+ * Exported for test/cli/rc15-fixes.test.ts banner integration test.
217
+ */
218
+ export function formatFileLine(total, scannable) {
219
+ if (scannable > 0 && scannable < total) {
220
+ return `${total} files (${scannable} scannable)`;
221
+ }
222
+ return `${total} files`;
223
+ }
224
+ export function buildWelcomeLines(state, version) {
153
225
  const logo = createCodehereLogo();
154
- const ver = colors.dim(`v${getVersion()}`);
155
- console.log();
156
- console.log(` ${logo} ${ver}`);
157
- console.log();
226
+ const ver = colors.dim(`v${version}`);
158
227
  const badges = [
159
228
  state.hasHooks ? colors.green('● hooks') : colors.dim('○ hooks'),
160
229
  state.hasAgents ? colors.green('● agents') : colors.dim('○ agents'),
161
230
  ];
162
- console.log(' ' + badges.join(' '));
163
- console.log();
164
- console.log(colors.dim(` ${state.name}`) + colors.dim(` · ${state.isGitRepo ? 'git' : 'dir'} · ${state.fileCount} files`));
165
- console.log();
166
- if (!state.hasHooks)
167
- console.log(' ' + colors.yellow('→') + ' ' + colors.cyan('/hook install') + colors.dim(' to activate safety scanning'));
168
- else if (!state.hasAgents)
169
- console.log(' ' + colors.yellow('→') + ' Install ' + colors.cyan('claude-code') + colors.dim(' / ') + colors.cyan('codex') + colors.dim(' / aider / opencode to delegate tasks'));
170
- console.log(colors.dim(' Type / for commands, or ask anything'));
171
- console.log();
231
+ const lines = [
232
+ '',
233
+ ` ${logo} ${ver}`,
234
+ '',
235
+ ' ' + badges.join(' '),
236
+ '',
237
+ colors.dim(` ${state.name}`) +
238
+ colors.dim(` · ${state.isGitRepo ? 'git' : 'dir'} · ${formatFileLine(state.fileCount, state.scannableCount)}`),
239
+ '',
240
+ ];
241
+ if (!state.hasHooks) {
242
+ lines.push(' ' + colors.yellow('→') + ' ' + colors.cyan('/hook install') + colors.dim(' to activate safety scanning'));
243
+ }
244
+ else if (!state.hasAgents) {
245
+ lines.push(' ' +
246
+ colors.yellow('→') +
247
+ ' Install ' +
248
+ colors.cyan('claude-code') +
249
+ colors.dim(' / ') +
250
+ colors.cyan('codex') +
251
+ colors.dim(' / aider / opencode to delegate tasks'));
252
+ }
253
+ lines.push(colors.dim(' Type / for commands, or ask anything'));
254
+ lines.push('');
255
+ return lines;
256
+ }
257
+ function printWelcome() {
258
+ const state = detectState();
259
+ for (const line of buildWelcomeLines(state, getVersion())) {
260
+ console.log(line);
261
+ }
172
262
  }
173
263
  // ── Main session ─────────────────────────────────────────────────────────
174
264
  export async function runInteractiveRepl() {
@@ -188,6 +278,7 @@ export async function runInteractiveRepl() {
188
278
  let escTimer = null;
189
279
  const cols = () => stdout.columns || 80;
190
280
  function truncate(s, maxWidth) {
281
+ // eslint-disable-next-line no-control-regex -- ANSI escape stripping requires the literal ESC byte
191
282
  const visible = s.replace(/\x1b\[[0-9;]*m/g, '');
192
283
  if (visible.length <= maxWidth)
193
284
  return s;
package/dist/ui.js CHANGED
@@ -326,9 +326,6 @@ export function createSessionWelcome() {
326
326
  const padding = Math.max(0, width - contentVisual - 4);
327
327
  return borderColor('│ ') + content + ' '.repeat(padding) + borderColor(' │\n');
328
328
  };
329
- // Calculate visual lengths (without ANSI codes)
330
- const logoVisual = visualLength(logo);
331
- const versionVisual = visualLength(version);
332
329
  let welcomeBox = '';
333
330
  welcomeBox += borderColor('┌' + '─'.repeat(width - 2) + '┐\n');
334
331
  // Header line with logo and version
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Typo guard for the bare `codehere <word>` action.
3
+ *
4
+ * Why this exists: codehere's bare action treats any string as a one-shot task
5
+ * prompt for the Conductor. Without a guard, a typo like `codehere helpp`
6
+ * silently spawns a Claude task to interpret "helpp" as a question, burning
7
+ * API tokens for what was clearly meant to be `codehere help`.
8
+ *
9
+ * Earlier rc.15 attempt (greedy edit-check + prefix match) over-matched and
10
+ * blocked legitimate single-word prompts like "task-runner" or "h". Codex
11
+ * caught the regression in review. This rc.16 version uses real Levenshtein,
12
+ * a length floor, and explicit kebab-case exemption.
13
+ */
14
+ /** Standard Levenshtein distance. Bounded inputs only — no allocation guard. */
15
+ export function levenshtein(a, b) {
16
+ const m = a.length;
17
+ const n = b.length;
18
+ if (m === 0)
19
+ return n;
20
+ if (n === 0)
21
+ return m;
22
+ const dp = new Array(n + 1);
23
+ for (let j = 0; j <= n; j++)
24
+ dp[j] = j;
25
+ for (let i = 1; i <= m; i++) {
26
+ let prev = dp[0];
27
+ dp[0] = i;
28
+ for (let j = 1; j <= n; j++) {
29
+ const tmp = dp[j];
30
+ dp[j] = a[i - 1] === b[j - 1] ? prev : Math.min(prev, dp[j], dp[j - 1]) + 1;
31
+ prev = tmp;
32
+ }
33
+ }
34
+ return dp[n];
35
+ }
36
+ /**
37
+ * Decide whether a bare-action input should be treated as a typo of a known
38
+ * subcommand. Returns the suggestions if it should block; null if it should
39
+ * fall through to the task spawner.
40
+ *
41
+ * Heuristics (intentionally narrow — false positives are worse than false negatives):
42
+ * - Single token (no whitespace), 4–14 chars (excludes "h", "go", etc.)
43
+ * - Pure alphabetic, no hyphens (kebab-case is a likely real prompt)
44
+ * - True Levenshtein distance ≤ 2 to a known command
45
+ * - Distance must be < input length (avoid 2-char words matching anything)
46
+ * - Returns up to 3 closest commands ranked by ascending distance
47
+ */
48
+ export function checkTypo(input, knownCommands) {
49
+ if (/\s/.test(input))
50
+ return null;
51
+ if (input.length < 4 || input.length > 14)
52
+ return null;
53
+ if (!/^[a-z]+$/i.test(input))
54
+ return null;
55
+ const lower = input.toLowerCase();
56
+ // Defensively normalize knownCommands to lowercase too — Codex rc.16 review
57
+ // flagged this for symmetry. Currently every Commander subcommand is lower
58
+ // already, but a future addition that registers an uppercase name shouldn't
59
+ // silently slip through the equality check.
60
+ const knownLower = knownCommands.map((n) => n.toLowerCase());
61
+ if (knownLower.includes(lower))
62
+ return null;
63
+ // Length-prefilter cheap reject before computing DP — same outcome, less work.
64
+ const scored = knownLower
65
+ .filter((n) => Math.abs(n.length - lower.length) <= 2)
66
+ .map((n) => ({ n, d: levenshtein(lower, n) }))
67
+ .filter((s) => s.d > 0 && s.d <= 2 && s.d < lower.length)
68
+ // Deterministic tie-breaker (alphabetical) so ranking is stable across runs.
69
+ .sort((a, b) => (a.d - b.d) || a.n.localeCompare(b.n))
70
+ .slice(0, 3);
71
+ return scored.length > 0 ? scored.map((s) => s.n) : null;
72
+ }
73
+ //# sourceMappingURL=typo-guard.js.map
@@ -29,7 +29,7 @@ export function getVersion() {
29
29
  return version;
30
30
  }
31
31
  }
32
- catch (error) {
32
+ catch {
33
33
  // Fallback if can't read package.json
34
34
  console.warn('Warning: Could not read version from package.json, using fallback');
35
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.3.0-rc.12",
3
+ "version": "0.3.0-rc.27",
4
4
  "description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, and OpenCode from one local web UI — every AI action traced, every file scored.",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",