codehere 0.5.0-alpha.6 → 0.6.0-alpha.1

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 (33) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +75 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  17. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  18. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  19. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  20. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  21. package/dist/infrastructure/orchestration/session-manager.js +95 -9
  22. package/dist/infrastructure/protocols/mcp-server-impl.js +533 -14
  23. package/dist/infrastructure/protocols/ui-page.js +2598 -212
  24. package/dist/infrastructure/security/permissions-config.js +234 -0
  25. package/dist/infrastructure/storage/project-thread-mirror.js +346 -24
  26. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  27. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  28. package/dist/presentation/cli/commands/budget-command.js +79 -0
  29. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  30. package/dist/presentation/cli/commands/permission-command.js +116 -0
  31. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  32. package/dist/utils/typo-guard.js +77 -17
  33. package/package.json +1 -1
@@ -158,6 +158,34 @@ export async function backfillThreadTitles(options) {
158
158
  result.titleAgentFailed++;
159
159
  continue;
160
160
  }
161
+ // Race guard: backfill scanned BEFORE the LLM call, but the LLM blocks
162
+ // for seconds. A user rename in that window leaves a userOverride:true
163
+ // marker the pre-scan never saw. Re-read the same file just before the
164
+ // write and skip if the user got there first. (Inlined rather than calling
165
+ // hasManualOverride() because that helper hardcodes the production
166
+ // projects dir; backfill supports a custom projectsDir for tests.)
167
+ let userBeatUs = false;
168
+ try {
169
+ const recheck = readFileSync(file, 'utf-8');
170
+ for (const line of recheck.split('\n')) {
171
+ const t = line.trim();
172
+ if (!t)
173
+ continue;
174
+ try {
175
+ const rec = JSON.parse(t);
176
+ if (rec._meta === 'project-update' && rec.userOverride === true) {
177
+ userBeatUs = true;
178
+ break;
179
+ }
180
+ }
181
+ catch { /* skip malformed */ }
182
+ }
183
+ }
184
+ catch { /* if we can't read, fall through and let the append fail */ }
185
+ if (userBeatUs) {
186
+ result.alreadyTitled++;
187
+ continue;
188
+ }
161
189
  try {
162
190
  const updateLine = JSON.stringify({
163
191
  _meta: 'project-update',
@@ -0,0 +1,79 @@
1
+ /**
2
+ * `codehere budget` — read or set the daily-spend budget.
3
+ *
4
+ * Subcommands:
5
+ * show — print current ceiling, today's spend, % used
6
+ * set <usd> — set daily ceiling in USD (e.g. `codehere budget set 5`)
7
+ * off — disable the budget (omits daily_usd from yaml)
8
+ *
9
+ * Reads + writes ~/.codehere/budget.yaml via budget-config.ts. Per
10
+ * claim_user_persona HIGH the YAML is hand-editable; the CLI is the
11
+ * fast path for users who want one number, one command.
12
+ */
13
+ import { loadBudget, saveBudget, budgetUsedPct, isOverBudget } from '../../../infrastructure/cost/budget-config.js';
14
+ import { computeSpendToday } from '../../../infrastructure/cost/spend-aggregator.js';
15
+ import { success, error, info, newline } from '../../../ui.js';
16
+ export async function executeBudgetCommand(subcommand, value) {
17
+ const sub = subcommand ?? 'show';
18
+ switch (sub) {
19
+ case 'show':
20
+ return showBudget();
21
+ case 'set':
22
+ if (!value) {
23
+ console.error(error('Usage:'), 'codehere budget set <amount-in-usd> e.g. codehere budget set 5');
24
+ process.exit(1);
25
+ }
26
+ return setBudget(value);
27
+ case 'off':
28
+ return disableBudget();
29
+ default:
30
+ console.error(error('Unknown subcommand:'), sub);
31
+ console.error(info('Try:'), 'codehere budget [show | set <usd> | off]');
32
+ process.exit(1);
33
+ }
34
+ }
35
+ function fmtUsd(n) {
36
+ return n >= 0.01 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`;
37
+ }
38
+ function showBudget() {
39
+ const cfg = loadBudget();
40
+ const spend = computeSpendToday();
41
+ newline();
42
+ console.log(info('Today\'s estimated spend:'), fmtUsd(spend.totalUsd), `(${spend.sessionCount} session${spend.sessionCount === 1 ? '' : 's'})`);
43
+ if (cfg.dailyUsd === null) {
44
+ console.log(info('Daily budget:'), 'none — no enforcement');
45
+ console.log(info('Set one with:'), 'codehere budget set 5');
46
+ }
47
+ else {
48
+ const pct = budgetUsedPct(spend.totalUsd, cfg) ?? 0;
49
+ const over = isOverBudget(spend.totalUsd, cfg);
50
+ console.log(info('Daily budget:'), fmtUsd(cfg.dailyUsd), `(${pct}% used)`);
51
+ if (over) {
52
+ console.log(error('Over budget.'), 'New spawns are blocked until midnight or the budget is raised.');
53
+ }
54
+ else if (pct >= cfg.warnAtPct) {
55
+ console.log(info('Approaching limit.'), `Warn threshold is ${cfg.warnAtPct}%.`);
56
+ }
57
+ }
58
+ newline();
59
+ }
60
+ function setBudget(value) {
61
+ const n = Number(value.replace(/[$,]/g, ''));
62
+ if (!Number.isFinite(n) || n <= 0) {
63
+ console.error(error('Invalid amount:'), value, '— expected a positive USD number.');
64
+ process.exit(1);
65
+ }
66
+ const cfg = loadBudget();
67
+ cfg.dailyUsd = n;
68
+ saveBudget(cfg);
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.'));
71
+ console.log(info('Disable with:'), 'codehere budget off');
72
+ }
73
+ function disableBudget() {
74
+ const cfg = loadBudget();
75
+ cfg.dailyUsd = null;
76
+ saveBudget(cfg);
77
+ console.log(success('Daily budget disabled.'), 'No enforcement on spawns.');
78
+ }
79
+ //# sourceMappingURL=budget-command.js.map
@@ -0,0 +1,101 @@
1
+ /**
2
+ * `codehere narrator` — read or customize the PM narrator prompt template.
3
+ *
4
+ * Subcommands:
5
+ * show — print active template + source (user / default)
6
+ * and the path codehere reads
7
+ * edit — write the default template to ~/.codehere/narrator-prompt.md
8
+ * if it doesn't already exist, then open in $EDITOR
9
+ * (or print the path if no $EDITOR set)
10
+ * reset — delete the user-edited file, fall back to default
11
+ *
12
+ * The template uses {{placeholder}} substitution. Required placeholders:
13
+ * {{prompt}}, {{agent}}, {{elapsed_seconds}}, {{steps}}
14
+ * Missing any → silent fallback to baked-in default at render time, so a
15
+ * typo never breaks narration.
16
+ *
17
+ * Per claim_user_persona HIGH: the narrator IS the agent's voice for
18
+ * non-technical users. Letting them control it is a v1 promise.
19
+ */
20
+ import { spawnSync } from 'child_process';
21
+ import { existsSync, readFileSync } from 'fs';
22
+ import { loadTemplate, resetTemplate, templatePath, writeDefaultTemplate, DEFAULT_TEMPLATE, } from '../../../application/use-cases/narrator-prompt-template.js';
23
+ import { error, info, newline, success } from '../../../ui.js';
24
+ export async function executeNarratorCommand(subcommand) {
25
+ const sub = subcommand ?? 'show';
26
+ switch (sub) {
27
+ case 'show':
28
+ return showNarrator();
29
+ case 'edit':
30
+ return editNarrator();
31
+ case 'reset':
32
+ return resetNarrator();
33
+ default:
34
+ console.error(error('Unknown subcommand:'), sub);
35
+ console.error(info('Try:'), 'codehere narrator [show | edit | reset]');
36
+ process.exit(1);
37
+ }
38
+ }
39
+ function showNarrator() {
40
+ const { source, template } = loadTemplate();
41
+ const path = templatePath();
42
+ newline();
43
+ console.log(info('Active template source:'), source === 'user' ? 'user-edited file' : 'baked-in default');
44
+ console.log(info('Path:'), path);
45
+ if (source === 'user') {
46
+ const sizeBytes = existsSync(path) ? readFileSync(path, 'utf-8').length : 0;
47
+ console.log(info('User file size:'), `${sizeBytes} bytes`);
48
+ }
49
+ else {
50
+ console.log(info('Customize with:'), 'codehere narrator edit');
51
+ }
52
+ newline();
53
+ console.log(info('Template (first 500 chars):'));
54
+ console.log(template.slice(0, 500) + (template.length > 500 ? '\n…(truncated)' : ''));
55
+ newline();
56
+ }
57
+ function editNarrator() {
58
+ const { wrote, path } = writeDefaultTemplate(false);
59
+ if (wrote) {
60
+ console.log(success('Wrote default template to:'), path);
61
+ }
62
+ else {
63
+ console.log(info('Existing template at:'), path);
64
+ }
65
+ const editor = process.env.EDITOR || process.env.VISUAL;
66
+ if (!editor) {
67
+ console.log(info('No $EDITOR set. Open this file in your editor of choice:'));
68
+ console.log(' ', path);
69
+ console.log(info('Then verify with:'), 'codehere narrator show');
70
+ return;
71
+ }
72
+ console.log(info('Opening in $EDITOR:'), editor);
73
+ // Fire the editor synchronously, inheriting tty so the user can edit.
74
+ const result = spawnSync(editor, [path], { stdio: 'inherit' });
75
+ if (result.status !== 0) {
76
+ console.error(error('Editor exited non-zero.'), 'Edits (if any) still saved.');
77
+ }
78
+ // Reload + report — gives the user immediate confirmation their edits parsed.
79
+ const after = loadTemplate();
80
+ if (after.source === 'user') {
81
+ console.log(success('Active template:'), 'user-edited file (parses, all placeholders present).');
82
+ }
83
+ else {
84
+ console.log(error('Template missing required placeholders.'), 'Falling back to default until fixed.');
85
+ console.log(info('Required:'), '{{prompt}}, {{agent}}, {{elapsed_seconds}}, {{steps}}');
86
+ }
87
+ }
88
+ function resetNarrator() {
89
+ const { removed, path } = resetTemplate();
90
+ if (removed) {
91
+ console.log(success('Removed user-edited template.'), 'Narrator now uses the baked-in default.');
92
+ console.log(info('File was at:'), path);
93
+ }
94
+ else {
95
+ console.log(info('No user-edited template to remove.'), 'Already on the baked-in default.');
96
+ }
97
+ // Don't load DEFAULT_TEMPLATE for any side effect — just acknowledge it's the
98
+ // active source now. (Imported above so future test shape stays available.)
99
+ void DEFAULT_TEMPLATE;
100
+ }
101
+ //# sourceMappingURL=narrator-command.js.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `codehere permission` — manage per-cwd .codehere/permissions.yaml.
3
+ *
4
+ * Subcommands:
5
+ * list — show current allow/deny entries
6
+ * allow <pattern> [--reason X] — append an allow entry
7
+ * deny <pattern> [--reason X] — append a deny entry (reserved; no-op today)
8
+ * remove <pattern> — drop the matching entry from both buckets
9
+ *
10
+ * Always operates on the CURRENT working directory's
11
+ * .codehere/permissions.yaml. Creates the file if missing. Writes are
12
+ * idempotent — re-running `allow X` won't add a duplicate.
13
+ *
14
+ * Per claim_user_persona HIGH: the CLI is the developer escape hatch;
15
+ * the slice-2 web-UI panel (forthcoming) wraps the same primitive for
16
+ * non-technical users. The YAML format is the source of truth either way.
17
+ */
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { join } from 'path';
20
+ import { loadPermissions, addPermissionEntry, removePermissionEntry, } from '../../../infrastructure/security/permissions-config.js';
21
+ import { colors, success, error, warning, info, newline } from '../../../ui.js';
22
+ export async function executePermissionCommand(subcommand, pattern, options = {}) {
23
+ const sub = subcommand ?? 'list';
24
+ switch (sub) {
25
+ case 'list':
26
+ return listPermissions();
27
+ case 'allow':
28
+ if (!pattern) {
29
+ console.error(error('Usage:'), 'codehere permission allow <pattern> [--reason "..."]');
30
+ process.exit(1);
31
+ }
32
+ return addEntry('allow', pattern, options.reason);
33
+ case 'deny':
34
+ if (!pattern) {
35
+ console.error(error('Usage:'), 'codehere permission deny <pattern> [--reason "..."]');
36
+ process.exit(1);
37
+ }
38
+ return addEntry('deny', pattern, options.reason);
39
+ case 'remove':
40
+ if (!pattern) {
41
+ console.error(error('Usage:'), 'codehere permission remove <pattern>');
42
+ process.exit(1);
43
+ }
44
+ return removeEntry(pattern);
45
+ default:
46
+ console.error(error('Unknown subcommand:'), sub);
47
+ console.error(info('Available:'), 'list · allow · deny · remove');
48
+ process.exit(1);
49
+ }
50
+ }
51
+ function permissionsPath() {
52
+ return join(process.cwd(), '.codehere', 'permissions.yaml');
53
+ }
54
+ function listPermissions() {
55
+ const cfg = loadPermissions(process.cwd());
56
+ if (cfg.allow.length === 0 && cfg.deny.length === 0) {
57
+ console.log(colors.dim(' (no permissions.yaml in this folder yet)'));
58
+ console.log(colors.dim(' Run `codehere permission allow <pattern> --reason "..."` to add an entry.'));
59
+ newline();
60
+ return;
61
+ }
62
+ if (cfg.allow.length > 0) {
63
+ console.log(success('allow:'));
64
+ for (const e of cfg.allow) {
65
+ console.log(` • ${e.pattern}`);
66
+ if (e.reason)
67
+ console.log(colors.dim(` reason: ${e.reason}`));
68
+ if (e.addedAt)
69
+ console.log(colors.dim(` added: ${e.addedAt}`));
70
+ }
71
+ }
72
+ if (cfg.deny.length > 0) {
73
+ console.log(warning('deny (reserved — currently no-op):'));
74
+ for (const e of cfg.deny) {
75
+ console.log(` • ${e.pattern}`);
76
+ if (e.reason)
77
+ console.log(colors.dim(` reason: ${e.reason}`));
78
+ }
79
+ }
80
+ newline();
81
+ }
82
+ function addEntry(bucket, pattern, reason) {
83
+ const result = addPermissionEntry({ cwd: process.cwd(), pattern, bucket, reason });
84
+ if (result.status === 'already_present') {
85
+ console.log(warning(`Pattern already in ${bucket}-list:`), pattern);
86
+ if (result.entry.reason)
87
+ console.log(colors.dim(` reason: ${result.entry.reason}`));
88
+ if (reason && reason !== result.entry.reason) {
89
+ console.log(colors.dim(' (use `codehere permission remove <pattern>` first to change the reason)'));
90
+ }
91
+ newline();
92
+ return;
93
+ }
94
+ console.log(success(`Added to ${bucket}-list:`), pattern);
95
+ if (reason)
96
+ console.log(colors.dim(` reason: ${reason}`));
97
+ console.log(colors.dim(` file: ${result.filePath}`));
98
+ newline();
99
+ }
100
+ function removeEntry(pattern) {
101
+ const { removed } = removePermissionEntry(process.cwd(), pattern);
102
+ if (removed === 0) {
103
+ console.log(warning('No entry found for pattern:'), pattern);
104
+ newline();
105
+ return;
106
+ }
107
+ console.log(success(`Removed ${removed} entr${removed === 1 ? 'y' : 'ies'} for:`), pattern);
108
+ newline();
109
+ }
110
+ // Ensure file is read on disk after writes (smoke test in unit tests)
111
+ export function _readRaw() {
112
+ if (!existsSync(permissionsPath()))
113
+ return '';
114
+ return readFileSync(permissionsPath(), 'utf-8');
115
+ }
116
+ //# sourceMappingURL=permission-command.js.map
@@ -7,10 +7,16 @@
7
7
  * This is the actual binary that Claude Code's hook system invokes.
8
8
  * It must be fast (<100ms) and never crash — any failure falls through to allow.
9
9
  *
10
+ * Trust-override audit (added 2026-05-06 per CC frontier Q1 — auditable
11
+ * permission trails): every fail-open path now writes an `overrideReason`
12
+ * audit entry so the audit log captures EVERY tool call, not just the ones
13
+ * the scanner could evaluate. Reviewers can `codehere audit --override-only`
14
+ * to see all cases where the safety policy didn't run normally.
15
+ *
10
16
  * Usage (by hook system, not user):
11
17
  * echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' | codehere scan-tool
12
18
  */
13
- import { processHookEvent } from '../../../infrastructure/hooks/claude-sdk-hook.js';
19
+ import { processHookEvent, appendOverrideAudit, } from '../../../infrastructure/hooks/claude-sdk-hook.js';
14
20
  export async function executeScanToolCommand(options) {
15
21
  // Read all stdin
16
22
  let raw = '';
@@ -20,7 +26,9 @@ export async function executeScanToolCommand(options) {
20
26
  }
21
27
  }
22
28
  catch {
23
- // If stdin read fails, allow the tool call
29
+ // If stdin read fails, allow the tool call — but log the override so
30
+ // the audit trail records that the scanner didn't get a chance to run.
31
+ appendOverrideAudit('stdin_read_failure');
24
32
  process.exit(0);
25
33
  }
26
34
  if (!raw.trim()) {
@@ -32,7 +40,12 @@ export async function executeScanToolCommand(options) {
32
40
  event = JSON.parse(raw);
33
41
  }
34
42
  catch {
35
- // Malformed JSON — allow by default (fail open for dev experience)
43
+ // Malformed JSON — allow by default (fail open for dev experience).
44
+ // We still record the override with a truncated copy of the raw payload
45
+ // so reviewers can see what arrived (helps diagnose hook misconfiguration).
46
+ appendOverrideAudit('event_parse_failure', {
47
+ toolInput: raw.substring(0, 500),
48
+ });
36
49
  if (options.debug) {
37
50
  process.stderr.write('[codehere] Warning: could not parse hook event JSON\n');
38
51
  }
@@ -41,9 +54,18 @@ export async function executeScanToolCommand(options) {
41
54
  try {
42
55
  const decision = processHookEvent(event);
43
56
  if (options.logOnly) {
44
- // Log-only mode: never block, but log everything
45
- if (options.debug && decision) {
46
- process.stderr.write(`[codehere] Would block: ${decision.reason}\n`);
57
+ // Log-only mode: never block, but log everything. If the scanner WOULD
58
+ // have blocked, record the override so the audit shows scanner intent
59
+ // vs actual outcome. Without this, --log-only mode produces an audit
60
+ // trail indistinguishable from a clean session.
61
+ if (decision?.decision === 'block') {
62
+ appendOverrideAudit('log_only_mode', {
63
+ sessionId: event.session_id,
64
+ toolName: event.tool_name,
65
+ });
66
+ if (options.debug) {
67
+ process.stderr.write(`[codehere] Would block: ${decision.reason}\n`);
68
+ }
47
69
  }
48
70
  process.exit(0);
49
71
  }
@@ -58,7 +80,14 @@ export async function executeScanToolCommand(options) {
58
80
  process.exit(0);
59
81
  }
60
82
  catch {
61
- // Any scan failure: fail open (allow), never break developer workflow
83
+ // Any scan failure: fail open (allow), never break developer workflow.
84
+ // Record the override so the audit trail shows the scanner crashed for
85
+ // this tool call — reviewers can correlate with the timestamp to find
86
+ // the underlying error.
87
+ appendOverrideAudit('scan_exception', {
88
+ sessionId: event.session_id,
89
+ toolName: event.tool_name,
90
+ });
62
91
  process.exit(0);
63
92
  }
64
93
  }
@@ -8,8 +8,16 @@
8
8
  *
9
9
  * Earlier rc.15 attempt (greedy edit-check + prefix match) over-matched and
10
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,
11
+ * caught the regression in review. The rc.16 version uses real Levenshtein,
12
12
  * a length floor, and explicit kebab-case exemption.
13
+ *
14
+ * v0.6 extension: handle multi-token input. Original guard bailed on any
15
+ * whitespace, which let `setupp foo bar` and `daemon start` silently route to
16
+ * the task spawner. Now: checkTypo tokenizes and runs typo-detection on the
17
+ * first token. looksLikeUnknownCommand catches the narrow `cmd subcmd` shape
18
+ * (two short alphabetic tokens, first not a known command, not a typo) so
19
+ * cases like `daemon start` get an "unknown command" message instead of
20
+ * silently spawning an LLM task.
13
21
  */
14
22
  /** Standard Levenshtein distance. Bounded inputs only — no allocation guard. */
15
23
  export function levenshtein(a, b) {
@@ -34,25 +42,15 @@ export function levenshtein(a, b) {
34
42
  return dp[n];
35
43
  }
36
44
  /**
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
45
+ * Internal: typo-check a single token against known commands.
46
+ * Returns up to 3 closest matches (Lev ≤ 2, distance < input length) or null.
47
47
  */
48
- export function checkTypo(input, knownCommands) {
49
- if (/\s/.test(input))
48
+ function checkTypoSingle(token, knownCommands) {
49
+ if (token.length < 4 || token.length > 14)
50
50
  return null;
51
- if (input.length < 4 || input.length > 14)
51
+ if (!/^[a-z]+$/i.test(token))
52
52
  return null;
53
- if (!/^[a-z]+$/i.test(input))
54
- return null;
55
- const lower = input.toLowerCase();
53
+ const lower = token.toLowerCase();
56
54
  // Defensively normalize knownCommands to lowercase too — Codex rc.16 review
57
55
  // flagged this for symmetry. Currently every Commander subcommand is lower
58
56
  // already, but a future addition that registers an uppercase name shouldn't
@@ -70,4 +68,66 @@ export function checkTypo(input, knownCommands) {
70
68
  .slice(0, 3);
71
69
  return scored.length > 0 ? scored.map((s) => s.n) : null;
72
70
  }
71
+ /**
72
+ * Decide whether a bare-action input contains a typo of a known subcommand.
73
+ * Returns suggestions if the FIRST token looks like a typo; null otherwise.
74
+ *
75
+ * Multi-token inputs (e.g. `setupp foo bar`) tokenize on whitespace and only
76
+ * the first token is typo-checked — the rest are treated as positional args
77
+ * the user meant to pass to the suggested command.
78
+ *
79
+ * Heuristics (intentionally narrow — false positives are worse than false negatives):
80
+ * - First token is 4–14 chars, pure alphabetic, no hyphens (kebab-case is a likely real prompt)
81
+ * - True Levenshtein distance ≤ 2 to a known command
82
+ * - Distance must be < input length (avoid 2-char words matching anything)
83
+ * - Returns up to 3 closest commands ranked by ascending distance
84
+ */
85
+ export function checkTypo(input, knownCommands) {
86
+ const trimmed = input.trim();
87
+ if (!trimmed)
88
+ return null;
89
+ const first = trimmed.split(/\s+/)[0];
90
+ return checkTypoSingle(first, knownCommands);
91
+ }
92
+ /**
93
+ * Detect inputs shaped like a typed-but-unknown subcommand invocation:
94
+ * exactly two short alphabetic tokens, first token NOT a known command and NOT
95
+ * a near-typo of one. Returns the unknown first token (for error formatting)
96
+ * or null if input doesn't match the shape.
97
+ *
98
+ * Catches cases the typo guard misses — e.g. `daemon start`, `webui open`,
99
+ * `nuke session` — where the first word isn't close enough to a real command
100
+ * for Levenshtein suggestions but is clearly a command attempt rather than a
101
+ * natural-language prompt.
102
+ *
103
+ * Conservative on purpose:
104
+ * - Exactly 2 tokens (3+ tokens are usually prompts: "fix the login bug")
105
+ * - Both tokens pure alphabetic (a slash/period in token 2 → file path → prompt)
106
+ * - First token 4-14 chars (skip "go run", "h x"); second 2-14 chars
107
+ * - First not a known command and not a typo (those branches handled elsewhere)
108
+ */
109
+ export function looksLikeUnknownCommand(input, knownCommands) {
110
+ const trimmed = input.trim();
111
+ if (!trimmed)
112
+ return null;
113
+ const tokens = trimmed.split(/\s+/);
114
+ if (tokens.length !== 2)
115
+ return null;
116
+ const [first, second] = tokens;
117
+ if (first.length < 4 || first.length > 14)
118
+ return null;
119
+ if (second.length < 2 || second.length > 14)
120
+ return null;
121
+ if (!/^[a-z]+$/i.test(first))
122
+ return null;
123
+ if (!/^[a-z]+$/i.test(second))
124
+ return null;
125
+ const lower = first.toLowerCase();
126
+ const knownLower = knownCommands.map((n) => n.toLowerCase());
127
+ if (knownLower.includes(lower))
128
+ return null;
129
+ if (checkTypoSingle(first, knownCommands))
130
+ return null;
131
+ return first;
132
+ }
73
133
  //# sourceMappingURL=typo-guard.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehere",
3
- "version": "0.5.0-alpha.6",
3
+ "version": "0.6.0-alpha.1",
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",