codehere 0.5.0-alpha.5 → 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.
- package/README.md +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +75 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +117 -10
- package/dist/infrastructure/orchestration/session-persistence.js +28 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +564 -13
- package/dist/infrastructure/protocols/ui-page.js +2728 -245
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +379 -24
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +1 -1
|
@@ -47,14 +47,36 @@ export function listProjects(options) {
|
|
|
47
47
|
});
|
|
48
48
|
const summaries = [];
|
|
49
49
|
for (const slug of slugs) {
|
|
50
|
-
|
|
50
|
+
// Pattern 4 — sidebar presence dots need agent attribution per project.
|
|
51
|
+
// Pull the full task list (lightweight — agent + startedAt only) so we
|
|
52
|
+
// can compute lastAgent + recentAgents (multi-agent stack). taskLimit:0
|
|
53
|
+
// would drop the data we need; fetch unbounded but discard the heavy
|
|
54
|
+
// fields (steps + artifacts) below.
|
|
55
|
+
const thread = readProjectThread(slug, { projectsDir: root });
|
|
51
56
|
if (thread) {
|
|
57
|
+
const agentChrono = thread.tasks
|
|
58
|
+
.filter((t) => typeof t.agent === 'string' && t.agent)
|
|
59
|
+
.sort((a, b) => b.startedAt - a.startedAt) // most-recent first
|
|
60
|
+
.map((t) => t.agent);
|
|
61
|
+
const lastAgent = agentChrono.length > 0 ? agentChrono[0] : null;
|
|
62
|
+
const seen = new Set();
|
|
63
|
+
const recentAgents = [];
|
|
64
|
+
for (const a of agentChrono) {
|
|
65
|
+
if (seen.has(a))
|
|
66
|
+
continue;
|
|
67
|
+
seen.add(a);
|
|
68
|
+
recentAgents.push(a);
|
|
69
|
+
if (recentAgents.length >= 3)
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
52
72
|
summaries.push({
|
|
53
73
|
slug: thread.slug,
|
|
54
74
|
cwd: thread.cwd,
|
|
55
75
|
title: thread.title,
|
|
56
76
|
taskCount: thread.taskCount,
|
|
57
77
|
lastActivityAt: thread.lastActivityAt,
|
|
78
|
+
lastAgent,
|
|
79
|
+
recentAgents,
|
|
58
80
|
});
|
|
59
81
|
}
|
|
60
82
|
}
|
|
@@ -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
|
-
|
|
46
|
-
|
|
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
|
}
|
package/dist/utils/typo-guard.js
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
38
|
-
*
|
|
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
|
-
|
|
49
|
-
if (
|
|
48
|
+
function checkTypoSingle(token, knownCommands) {
|
|
49
|
+
if (token.length < 4 || token.length > 14)
|
|
50
50
|
return null;
|
|
51
|
-
if (
|
|
51
|
+
if (!/^[a-z]+$/i.test(token))
|
|
52
52
|
return null;
|
|
53
|
-
|
|
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.
|
|
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",
|