profclaw 2.3.0 → 2.4.0

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 (60) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +5 -2
  3. package/config/pricing.yml +26 -2
  4. package/dist/agents/command-sandbox.d.ts +93 -0
  5. package/dist/agents/command-sandbox.js +235 -0
  6. package/dist/agents/executor-runner.d.ts +135 -0
  7. package/dist/agents/executor-runner.js +303 -0
  8. package/dist/agents/executor.d.ts +2 -0
  9. package/dist/agents/executor.js +35 -2
  10. package/dist/agents/output-truncator.d.ts +59 -0
  11. package/dist/agents/output-truncator.js +128 -0
  12. package/dist/agents/receipt/receipt-cli.d.ts +43 -0
  13. package/dist/agents/receipt/receipt-cli.js +82 -0
  14. package/dist/agents/receipt/receipt-types.d.ts +136 -0
  15. package/dist/agents/receipt/receipt-types.js +19 -0
  16. package/dist/agents/receipt/receipt-verify.d.ts +19 -0
  17. package/dist/agents/receipt/receipt-verify.js +176 -0
  18. package/dist/agents/receipt/receipt-view.d.ts +29 -0
  19. package/dist/agents/receipt/receipt-view.js +227 -0
  20. package/dist/agents/receipt/receipt-writer.d.ts +47 -0
  21. package/dist/agents/receipt/receipt-writer.js +186 -0
  22. package/dist/agents/result-store.d.ts +6 -0
  23. package/dist/agents/result-store.js +43 -2
  24. package/dist/agents/session-diff.d.ts +6 -0
  25. package/dist/agents/session-diff.js +10 -19
  26. package/dist/agents/tool-selector.d.ts +123 -0
  27. package/dist/agents/tool-selector.js +321 -0
  28. package/dist/agents/verified-run-git.d.ts +49 -0
  29. package/dist/agents/verified-run-git.js +82 -0
  30. package/dist/agents/verified-run.d.ts +149 -0
  31. package/dist/agents/verified-run.js +402 -0
  32. package/dist/agents/verifier.d.ts +64 -0
  33. package/dist/agents/verifier.js +186 -0
  34. package/dist/agents/worktree-tools.d.ts +47 -0
  35. package/dist/agents/worktree-tools.js +246 -0
  36. package/dist/chat/agentic-executor.js +14 -6
  37. package/dist/chat/execution/sandbox.d.ts +8 -0
  38. package/dist/chat/execution/sandbox.js +25 -1
  39. package/dist/chat/execution/tools/tts-speak.d.ts +2 -2
  40. package/dist/chat/meta-tool-handlers.d.ts +18 -0
  41. package/dist/chat/meta-tool-handlers.js +51 -0
  42. package/dist/chat/system-prompts.js +6 -1
  43. package/dist/chat/tool-handler.d.ts +2 -0
  44. package/dist/chat/tool-handler.js +47 -3
  45. package/dist/cli/commands/receipt.d.ts +7 -0
  46. package/dist/cli/commands/receipt.js +90 -0
  47. package/dist/cli/commands/run.d.ts +28 -0
  48. package/dist/cli/commands/run.js +204 -0
  49. package/dist/cli/index.js +4 -0
  50. package/dist/costs/token-tracker.d.ts +10 -1
  51. package/dist/costs/token-tracker.js +37 -5
  52. package/dist/providers/ai-sdk.js +33 -37
  53. package/dist/providers/core/models.js +59 -17
  54. package/dist/providers/core/types.d.ts +4 -0
  55. package/dist/providers/prompt-cache.d.ts +68 -0
  56. package/dist/providers/prompt-cache.js +165 -0
  57. package/dist/providers/smart-router.js +11 -3
  58. package/dist/routes/chat.js +22 -5
  59. package/dist/server.js +3 -0
  60. package/package.json +1 -1
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Meta-tool handlers: load_tools and fetch_result.
3
+ *
4
+ * Kept separate from tool-handler.ts so the handler stays small. These are pure
5
+ * functions over injected dependencies, which also makes them easy to test.
6
+ */
7
+ import { readStoredRange, getTruncationConfig } from '../agents/output-truncator.js';
8
+ import { TOOL_GROUPS, describeTool, parseFetchResultArgs, parseLoadToolsArgs, toolsInGroup, } from '../agents/tool-selector.js';
9
+ export async function runLoadTools(args, deps) {
10
+ const parsed = parseLoadToolsArgs(args);
11
+ if (!parsed) {
12
+ return { success: false, error: 'load_tools expects { groups: string[] }' };
13
+ }
14
+ const loaded = deps.session.loadGroups(parsed.groups);
15
+ const unknown = parsed.groups.filter((g) => !loaded.includes(g.trim().toLowerCase()));
16
+ const tools = loaded.flatMap((group) => toolsInGroup(deps.allTools, group).map(describeTool));
17
+ const response = {
18
+ success: loaded.length > 0,
19
+ loaded,
20
+ tools,
21
+ note: 'These tools are callable from the next turn. Use invoke to call one now.',
22
+ };
23
+ if (unknown.length > 0) {
24
+ response.unknownGroups = unknown;
25
+ response.validGroups = TOOL_GROUPS.map((g) => g.name);
26
+ }
27
+ if (parsed.invoke) {
28
+ const target = parsed.invoke.tool;
29
+ const inLoaded = tools.some((t) => t.name === target);
30
+ if (!inLoaded) {
31
+ response.invokeError = `Tool "${target}" is not in the loaded groups`;
32
+ }
33
+ else {
34
+ response.invokeResult = await deps.invoke(target, parsed.invoke.args ?? {});
35
+ }
36
+ }
37
+ return response;
38
+ }
39
+ export async function runFetchResult(args, store) {
40
+ const parsed = parseFetchResultArgs(args);
41
+ if (!parsed) {
42
+ return { success: false, error: 'fetch_result expects { result_id, offset?, length? }' };
43
+ }
44
+ const config = getTruncationConfig();
45
+ const range = await readStoredRange(store, parsed.result_id, parsed.offset ?? 0, parsed.length ?? config.fetchMaxChars, config);
46
+ if (!range) {
47
+ return { success: false, error: `No stored result with id "${parsed.result_id}"` };
48
+ }
49
+ return range;
50
+ }
51
+ //# sourceMappingURL=meta-tool-handlers.js.map
@@ -350,7 +350,12 @@ ${context.user.role ? `- Role: ${context.user.role}` : ''}`);
350
350
  // Only include top 15 most relevant skills, not all 55
351
351
  const prioritySkills = entries
352
352
  .filter((e) => e.enabled !== false)
353
- .sort((a, b) => (b.metadata?.priority ?? 0) - (a.metadata?.priority ?? 0))
353
+ .sort((a, b) => {
354
+ const pa = a.metadata?.priority ?? 0;
355
+ const pb = b.metadata?.priority ?? 0;
356
+ // Name tie-break keeps the cached prompt prefix byte-stable
357
+ return pb - pa || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
358
+ })
354
359
  .slice(0, 15);
355
360
  const skillLines = prioritySkills
356
361
  .map((e) => `- ${e.name}: ${e.description || 'No description'}`)
@@ -25,6 +25,8 @@ export interface ChatToolHandler {
25
25
  }>;
26
26
  /** Handle approval decision */
27
27
  handleApproval(approvalId: string, decision: 'allow-once' | 'allow-always' | 'deny'): Promise<ToolExecutionResult | null>;
28
+ /** Delete spilled tool-result temp files. Call when the request/run ends. */
29
+ dispose(): Promise<void>;
28
30
  }
29
31
  export declare function createChatToolHandler(options: ChatToolHandlerOptions): Promise<ChatToolHandler>;
30
32
  /**
@@ -9,6 +9,10 @@ import { randomUUID } from 'node:crypto';
9
9
  import { getToolExecutor, getToolRegistry, initializeToolExecution, } from './execution/index.js';
10
10
  import { logger } from '../utils/logger.js';
11
11
  import { SAFE_BROWSER_TOOL_NAMES } from '../browser/index.js';
12
+ import { ResultStore } from '../agents/result-store.js';
13
+ import { truncateAndStore, isTruncationEnabled } from '../agents/output-truncator.js';
14
+ import { LOAD_TOOLS_NAME, FETCH_RESULT_NAME, getToolSelectorSession, } from '../agents/tool-selector.js';
15
+ import { runLoadTools, runFetchResult } from './meta-tool-handlers.js';
12
16
  // In-Memory Session Manager for Chat
13
17
  class ChatSessionManager {
14
18
  sessions = new Map();
@@ -78,6 +82,23 @@ export async function createChatToolHandler(options) {
78
82
  const registry = getToolRegistry();
79
83
  const executor = getToolExecutor();
80
84
  const sessionManager = new ChatSessionManager();
85
+ const resultStore = new ResultStore(options.conversationId.replace(/[^a-zA-Z0-9_-]/g, '_'));
86
+ /** Cap large results before they reach the model; the full result stays in the store. */
87
+ const capForModel = async (toolName, toolCallId, data) => {
88
+ if (!isTruncationEnabled() || toolName === FETCH_RESULT_NAME || toolName === LOAD_TOOLS_NAME) {
89
+ return data;
90
+ }
91
+ try {
92
+ return (await truncateAndStore(resultStore, toolCallId, data)).value;
93
+ }
94
+ catch (error) {
95
+ logger.warn(`[ChatToolHandler] Output truncation failed for ${toolName}`, {
96
+ component: 'ChatToolHandler',
97
+ error: error instanceof Error ? error.message : String(error),
98
+ });
99
+ return data;
100
+ }
101
+ };
81
102
  // Build security policy
82
103
  const securityPolicy = {
83
104
  mode: options.securityMode || 'ask',
@@ -93,7 +114,7 @@ export async function createChatToolHandler(options) {
93
114
  securityPolicy,
94
115
  sessionManager,
95
116
  });
96
- return {
117
+ const handler = {
97
118
  getTools() {
98
119
  const tools = registry.list();
99
120
  return tools.map((tool) => ({
@@ -107,7 +128,26 @@ export async function createChatToolHandler(options) {
107
128
  component: 'ChatToolHandler',
108
129
  toolCallId,
109
130
  });
131
+ // Meta-tools are served locally, not through the registry
132
+ if (toolName === LOAD_TOOLS_NAME) {
133
+ const allTools = registry.list().map((t) => ({
134
+ name: t.name,
135
+ description: t.description,
136
+ parameters: t.parameters,
137
+ }));
138
+ return {
139
+ result: await runLoadTools(args, {
140
+ session: getToolSelectorSession(options.conversationId),
141
+ allTools,
142
+ invoke: async (tool, toolArgs) => (await handler.executeTool(tool, toolArgs, randomUUID())).result,
143
+ }),
144
+ };
145
+ }
146
+ if (toolName === FETCH_RESULT_NAME) {
147
+ return { result: await runFetchResult(args, resultStore) };
148
+ }
110
149
  try {
150
+ getToolSelectorSession(options.conversationId).noteToolUse(toolName);
111
151
  const context = createContext(toolCallId);
112
152
  const result = await executor.execute({
113
153
  id: toolCallId,
@@ -134,7 +174,7 @@ export async function createChatToolHandler(options) {
134
174
  // The AI only sees `result` — `output` is preserved for UI rendering
135
175
  if (result.result.success) {
136
176
  // Extract clean data for the AI model
137
- const aiResult = result.result.data ?? { success: true };
177
+ const aiResult = await capForModel(toolName, toolCallId, result.result.data ?? { success: true });
138
178
  return {
139
179
  result: aiResult,
140
180
  output: result.result.output, // Sidecar for UI display
@@ -166,6 +206,9 @@ export async function createChatToolHandler(options) {
166
206
  params: a.params,
167
207
  }));
168
208
  },
209
+ async dispose() {
210
+ await resultStore.cleanup();
211
+ },
169
212
  async handleApproval(approvalId, decision) {
170
213
  logger.info(`[ChatToolHandler] Handling approval: ${approvalId} -> ${decision}`, {
171
214
  component: 'ChatToolHandler',
@@ -176,7 +219,7 @@ export async function createChatToolHandler(options) {
176
219
  return null;
177
220
  }
178
221
  if (result.result.success) {
179
- const aiResult = result.result.data ?? { success: true };
222
+ const aiResult = await capForModel(approvalId, randomUUID(), result.result.data ?? { success: true });
180
223
  return {
181
224
  result: aiResult,
182
225
  output: result.result.output,
@@ -200,6 +243,7 @@ export async function createChatToolHandler(options) {
200
243
  }
201
244
  },
202
245
  };
246
+ return handler;
203
247
  }
204
248
  // Tool Categories for Chat
205
249
  /**
@@ -0,0 +1,7 @@
1
+ /**
2
+ * profclaw receipt: verify, view and key generation for run receipts.
3
+ * Spec: docs/specs/run-receipt-v0.md
4
+ */
5
+ import { Command } from 'commander';
6
+ export declare function receiptCommand(): Command;
7
+ //# sourceMappingURL=receipt.d.ts.map
@@ -0,0 +1,90 @@
1
+ /**
2
+ * profclaw receipt: verify, view and key generation for run receipts.
3
+ * Spec: docs/specs/run-receipt-v0.md
4
+ */
5
+ import { readFileSync, writeFileSync } from 'node:fs';
6
+ import { Command } from 'commander';
7
+ import { verifyReceiptText, parseReceiptText } from '../../agents/receipt/receipt-verify.js';
8
+ import { renderHtml, renderTerminalSummary } from '../../agents/receipt/receipt-view.js';
9
+ import { generateKeyPairFiles } from '../../agents/receipt/receipt-cli.js';
10
+ import { error, info, success } from '../utils/output.js';
11
+ function readOrExit(file) {
12
+ try {
13
+ return readFileSync(file, 'utf-8');
14
+ }
15
+ catch (err) {
16
+ error(`Cannot read ${file}: ${err instanceof Error ? err.message : 'unknown error'}`);
17
+ process.exitCode = 1;
18
+ return null;
19
+ }
20
+ }
21
+ export function receiptCommand() {
22
+ const cmd = new Command('receipt').description('Verify, view and sign tamper-evident run receipts');
23
+ cmd
24
+ .command('verify')
25
+ .description('Check a receipt: exit 0 if intact, 1 if tampered, truncated or invalid')
26
+ .argument('<file>', 'Path to receipt.jsonl')
27
+ .option('--pubkey <file>', 'Trusted Ed25519 public key (PEM); the receipt must be signed by it')
28
+ .option('--require-signature', 'Fail if the receipt is not signed')
29
+ .action((file, opts) => {
30
+ const text = readOrExit(file);
31
+ if (text === null)
32
+ return;
33
+ let expectedPublicKey;
34
+ if (opts.pubkey) {
35
+ const key = readOrExit(opts.pubkey);
36
+ if (key === null)
37
+ return;
38
+ expectedPublicKey = key;
39
+ }
40
+ const result = verifyReceiptText(text, { expectedPublicKey, requireSignature: opts.requireSignature });
41
+ if (result.ok) {
42
+ success(result.message);
43
+ info('This shows the receipt was not altered after it was written. It does not prove the run happened.');
44
+ process.exitCode = 0;
45
+ }
46
+ else {
47
+ error(`TAMPERED or invalid at seq ${result.firstBadSeq ?? 0} (${result.reason ?? 'unknown'}): ${result.message}`);
48
+ process.exitCode = 1;
49
+ }
50
+ });
51
+ cmd
52
+ .command('view')
53
+ .description('Print a summary, or write a self-contained HTML viewer with --html')
54
+ .argument('<file>', 'Path to receipt.jsonl')
55
+ .option('--html <out>', 'Write a single-file HTML viewer to this path')
56
+ .action((file, opts) => {
57
+ const text = readOrExit(file);
58
+ if (text === null)
59
+ return;
60
+ const verify = verifyReceiptText(text);
61
+ const events = parseReceiptText(text);
62
+ if (opts.html) {
63
+ writeFileSync(opts.html, renderHtml(events, verify), 'utf-8');
64
+ info(`Wrote ${opts.html}`);
65
+ }
66
+ else {
67
+ process.stdout.write(renderTerminalSummary(events, verify));
68
+ }
69
+ if (!verify.ok)
70
+ process.exitCode = 1;
71
+ });
72
+ cmd
73
+ .command('keygen')
74
+ .description('Write an Ed25519 keypair for signing receipts (private key mode 0600)')
75
+ .argument('<dir>', 'Directory to write receipt-key.pem and receipt-key.pub.pem into')
76
+ .action((dir) => {
77
+ try {
78
+ const files = generateKeyPairFiles(dir);
79
+ success(`Private key: ${files.privateKeyPath} (keep it off the machine that runs agents if you can)`);
80
+ info(`Public key: ${files.publicKeyPath}`);
81
+ info('Sign with --sign-key <private key> or env PROFCLAW_RECEIPT_KEY; check with: profclaw receipt verify <file> --pubkey <public key>');
82
+ }
83
+ catch (err) {
84
+ error(err instanceof Error ? err.message : 'Key generation failed');
85
+ process.exitCode = 1;
86
+ }
87
+ });
88
+ return cmd;
89
+ }
90
+ //# sourceMappingURL=receipt.js.map
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Verified Run CLI Command
3
+ *
4
+ * Usage:
5
+ * profclaw run "<goal>" --verify "pnpm test" (built-in agent)
6
+ * profclaw run "<goal>" --verify "pnpm test" --agent-cmd "my-agent-cli"
7
+ *
8
+ * Done means verified: the run only succeeds when the verify command exits 0.
9
+ * Work happens in an isolated git worktree on a local branch. Nothing is
10
+ * pushed and no PR is opened.
11
+ *
12
+ * By default the built-in model-backed agent runs inside the worktree, starting
13
+ * on the smart router's cheap model and escalating a tier after repeated
14
+ * verifier failures. --agent-cmd overrides it with an external agent; the goal
15
+ * and feedback are written to that agent's stdin, never interpolated into a
16
+ * shell string.
17
+ */
18
+ import { Command } from 'commander';
19
+ import type { AgentAttemptInput, AgentAttemptResult, AgentRunner } from '../../agents/verified-run.js';
20
+ /** Runs an external agent command, feeding the prompt on stdin. */
21
+ export declare class CommandAgent implements AgentRunner {
22
+ private readonly command;
23
+ constructor(command: string);
24
+ runAttempt(input: AgentAttemptInput): Promise<AgentAttemptResult>;
25
+ }
26
+ export declare function buildPrompt(input: AgentAttemptInput): string;
27
+ export declare function runCommand(): Command;
28
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Verified Run CLI Command
3
+ *
4
+ * Usage:
5
+ * profclaw run "<goal>" --verify "pnpm test" (built-in agent)
6
+ * profclaw run "<goal>" --verify "pnpm test" --agent-cmd "my-agent-cli"
7
+ *
8
+ * Done means verified: the run only succeeds when the verify command exits 0.
9
+ * Work happens in an isolated git worktree on a local branch. Nothing is
10
+ * pushed and no PR is opened.
11
+ *
12
+ * By default the built-in model-backed agent runs inside the worktree, starting
13
+ * on the smart router's cheap model and escalating a tier after repeated
14
+ * verifier failures. --agent-cmd overrides it with an external agent; the goal
15
+ * and feedback are written to that agent's stdin, never interpolated into a
16
+ * shell string.
17
+ */
18
+ import { spawn } from 'node:child_process';
19
+ import { Command } from 'commander';
20
+ import { describeSandbox, resolveSandboxMode } from '../../agents/command-sandbox.js';
21
+ import { CommandVerifier } from '../../agents/verifier.js';
22
+ import { runVerifiedGoal, resolveLimits } from '../../agents/verified-run.js';
23
+ import { createDefaultExecutorRunner } from '../../agents/executor-runner.js';
24
+ import { setupRunReceipt, toolVersion } from '../../agents/receipt/receipt-cli.js';
25
+ import { error, info, success, warn, formatCost, formatTokens } from '../utils/output.js';
26
+ /** Runs an external agent command, feeding the prompt on stdin. */
27
+ export class CommandAgent {
28
+ command;
29
+ constructor(command) {
30
+ this.command = command;
31
+ }
32
+ runAttempt(input) {
33
+ const prompt = buildPrompt(input);
34
+ return new Promise((resolve, reject) => {
35
+ const child = spawn('/bin/sh', ['-c', this.command], {
36
+ cwd: input.workdir,
37
+ stdio: ['pipe', 'pipe', 'pipe'],
38
+ });
39
+ let out = '';
40
+ child.stdout.on('data', (d) => {
41
+ out = (out + d.toString('utf-8')).slice(-4000);
42
+ });
43
+ child.stderr.on('data', (d) => {
44
+ out = (out + d.toString('utf-8')).slice(-4000);
45
+ });
46
+ input.signal?.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
47
+ child.on('error', reject);
48
+ child.on('close', (code) => {
49
+ if (code === 0)
50
+ resolve({ summary: out.trim().slice(-500), tokensUsed: 0, costUsd: 0 });
51
+ else
52
+ reject(new Error(`agent command exited with code ${code ?? 'null'}: ${out.trim().slice(-500)}`));
53
+ });
54
+ child.stdin.on('error', () => undefined);
55
+ child.stdin.end(prompt);
56
+ });
57
+ }
58
+ }
59
+ export function buildPrompt(input) {
60
+ const parts = [
61
+ `Goal: ${input.goal}`,
62
+ `Work only inside: ${input.workdir}`,
63
+ `Your work is judged by running: ${input.verifyCommand}`,
64
+ 'Do not push to any remote or open pull requests.',
65
+ ];
66
+ if (input.feedback)
67
+ parts.push('', `Attempt ${input.attempt}. Previous attempt feedback:`, input.feedback);
68
+ return parts.join('\n') + '\n';
69
+ }
70
+ function parseNumber(raw, name) {
71
+ if (raw === undefined)
72
+ return undefined;
73
+ const n = Number(raw);
74
+ if (!Number.isFinite(n) || n < 0)
75
+ throw new Error(`${name} must be a non-negative number`);
76
+ return n;
77
+ }
78
+ function formatAttemptUsage(record) {
79
+ const parts = [`${formatTokens(record.tokensUsed)} tokens`];
80
+ if (record.cacheHitRate !== undefined)
81
+ parts.push(`cache hit ${(record.cacheHitRate * 100).toFixed(0)}%`);
82
+ parts.push(formatCost(record.costUsd));
83
+ if (record.model)
84
+ parts.push(record.model);
85
+ return parts.join(', ');
86
+ }
87
+ function printRunnerEvent(event) {
88
+ const { attempt, choice, escalated } = event;
89
+ const prefix = escalated ? 'Escalating to' : 'Using';
90
+ info(`Attempt ${attempt}: ${prefix} ${choice.provider}/${choice.model} (${choice.tier})`);
91
+ }
92
+ function printEvent(event) {
93
+ if (event.type === 'start')
94
+ info(`Run ${event.runId} on branch ${event.branch}`);
95
+ else if (event.type === 'attempt_start')
96
+ info(`Attempt ${event.attempt}...`);
97
+ else if (event.type === 'attempt_end') {
98
+ const { record } = event;
99
+ const line = `Attempt ${record.attempt}: ${record.outcome}${record.rolledBack ? ' (rolled back)' : ''} [${formatAttemptUsage(record)}]`;
100
+ if (record.outcome === 'verified')
101
+ success(line);
102
+ else
103
+ warn(line);
104
+ }
105
+ }
106
+ export function runCommand() {
107
+ return new Command('run')
108
+ .description('Run an agent toward a goal and only finish when a verify command passes')
109
+ .argument('<goal>', 'What the agent should accomplish')
110
+ .requiredOption('--verify <command>', 'Command that must exit 0 (tests, typecheck, lint, build)')
111
+ .option('--agent-cmd <command>', 'External agent command (overrides the built-in agent); receives the prompt on stdin')
112
+ .option('--model <ids>', 'Model, or comma separated ladder cheapest first (env PROFCLAW_RUN_MODEL); default: smart router')
113
+ .option('--escalate-after <n>', 'Verifier failures in a row before moving up a model tier, 0 disables (env PROFCLAW_RUN_ESCALATE_AFTER)')
114
+ .option('--max-attempts <n>', 'Maximum attempts (env PROFCLAW_RUN_MAX_ATTEMPTS)')
115
+ .option('--max-tokens <n>', 'Token budget, 0 disables (env PROFCLAW_RUN_MAX_TOKENS)')
116
+ .option('--max-cost <usd>', 'Dollar budget, 0 disables (env PROFCLAW_RUN_MAX_COST_USD)')
117
+ .option('--breaker <n>', 'Non-improving attempts before the circuit breaker trips (env PROFCLAW_RUN_BREAKER_THRESHOLD)')
118
+ .option('--verify-timeout <ms>', 'Verifier timeout (env PROFCLAW_RUN_VERIFY_TIMEOUT_MS)')
119
+ .option('--branch <name>', 'Branch name for the run (default profclaw/run-<id>)')
120
+ .option('--sandbox-verify', 'Also run the verify command under the OS sandbox (env PROFCLAW_RUN_SANDBOX_VERIFY=1); default off')
121
+ .option('--cleanup', 'Remove the worktree afterwards (the branch is kept)')
122
+ .option('--no-receipt', 'Do not write a tamper-evident receipt.jsonl for the run')
123
+ .option('--sign-key <path>', 'Ed25519 private key (PEM) to sign the receipt (env PROFCLAW_RECEIPT_KEY)')
124
+ .option('--json', 'Output the result as JSON')
125
+ .action(async (goal, options) => {
126
+ try {
127
+ const flags = {
128
+ maxAttempts: parseNumber(options.maxAttempts, '--max-attempts'),
129
+ maxTokens: parseNumber(options.maxTokens, '--max-tokens'),
130
+ maxCostUsd: parseNumber(options.maxCost, '--max-cost'),
131
+ breakerThreshold: parseNumber(options.breaker, '--breaker'),
132
+ verifyTimeoutMs: parseNumber(options.verifyTimeout, '--verify-timeout'),
133
+ };
134
+ for (const key of Object.keys(flags)) {
135
+ if (flags[key] === undefined)
136
+ delete flags[key];
137
+ }
138
+ const verifyCommand = options.verify;
139
+ const sandboxMode = resolveSandboxMode();
140
+ const sandboxVerify = options.sandboxVerify === true || process.env.PROFCLAW_RUN_SANDBOX_VERIFY === '1';
141
+ if (!options.json)
142
+ info(`${describeSandbox(sandboxMode)}${sandboxVerify ? '; verify command sandboxed' : ''}`);
143
+ const limits = resolveLimits(flags);
144
+ const rc = setupRunReceipt({ enabled: options.receipt !== false, projectRoot: process.cwd(), signKeyPath: options.signKey });
145
+ const agent = options.agentCmd
146
+ ? new CommandAgent(options.agentCmd)
147
+ : await createDefaultExecutorRunner({
148
+ goal,
149
+ model: options.model,
150
+ config: { escalateAfter: parseNumber(options.escalateAfter, '--escalate-after') },
151
+ maxTokens: limits.maxTokens,
152
+ maxCostUsd: limits.maxCostUsd,
153
+ onEvent: options.json ? undefined : printRunnerEvent,
154
+ onToolCall: rc.onToolCall,
155
+ });
156
+ const result = await runVerifiedGoal({
157
+ goal,
158
+ verifyCommand,
159
+ agent,
160
+ projectRoot: process.cwd(),
161
+ runId: rc.runId,
162
+ receipt: rc.receipt,
163
+ toolInfo: { name: 'profclaw', version: toolVersion() },
164
+ limits: flags,
165
+ branchName: options.branch,
166
+ removeWorktree: options.cleanup,
167
+ onEvent: options.json ? undefined : printEvent,
168
+ createVerifier: (cwd) => new CommandVerifier(verifyCommand, {
169
+ cwd,
170
+ timeoutMs: flags.verifyTimeoutMs ?? Number(process.env.PROFCLAW_RUN_VERIFY_TIMEOUT_MS ?? 600_000),
171
+ maxOutputChars: Number(process.env.PROFCLAW_RUN_MAX_OUTPUT_CHARS ?? 4_000),
172
+ sandbox: sandboxVerify ? { mode: sandboxMode } : undefined,
173
+ }),
174
+ });
175
+ if (options.json) {
176
+ console.log(JSON.stringify({ ...result, diff: undefined }, null, 2));
177
+ }
178
+ else if (result.verified) {
179
+ success(`Verified after ${result.attempts.length} attempt(s), cost ${formatCost(result.totalCostUsd)}`);
180
+ info(`Branch ${result.branch} is ready for review (not pushed).`);
181
+ if (result.reportPath)
182
+ info(`Evidence: ${result.reportPath}`);
183
+ if (rc.receipt)
184
+ info(`Receipt: ${rc.receipt.path}`);
185
+ }
186
+ else if (result.stopReason === 'already_passing') {
187
+ warn('The verify command already passes before any change, so no attempt was made.');
188
+ if (result.reportPath)
189
+ info(`Report: ${result.reportPath}`);
190
+ }
191
+ else {
192
+ error(`Not verified (${result.stopReason}) after ${result.attempts.length} attempt(s)`);
193
+ if (result.reportPath)
194
+ info(`Report: ${result.reportPath}`);
195
+ }
196
+ process.exitCode = result.verified || result.stopReason === 'already_passing' ? 0 : 1;
197
+ }
198
+ catch (err) {
199
+ error(err instanceof Error ? err.message : 'Verified run failed');
200
+ process.exitCode = 1;
201
+ }
202
+ });
203
+ }
204
+ //# sourceMappingURL=run.js.map
package/dist/cli/index.js CHANGED
@@ -37,6 +37,8 @@ import { importCommands } from './commands/import.js';
37
37
  import { historyCommands } from './commands/history.js';
38
38
  import { planCommands } from './commands/plan.js';
39
39
  import { initCommand } from './commands/init.js';
40
+ import { runCommand } from './commands/run.js';
41
+ import { receiptCommand } from './commands/receipt.js';
40
42
  const VERSION = '2.0.0';
41
43
  // ASCII Banner
42
44
  function showBanner() {
@@ -92,6 +94,8 @@ program.addCommand(importCommands());
92
94
  program.addCommand(historyCommands());
93
95
  program.addCommand(planCommands());
94
96
  program.addCommand(initCommand());
97
+ program.addCommand(runCommand());
98
+ program.addCommand(receiptCommand());
95
99
  // Default action (no command) - show banner and help
96
100
  program.action(() => {
97
101
  showBanner();
@@ -2,6 +2,12 @@ export interface UsageSummary {
2
2
  totalTokens: number;
3
3
  inputTokens: number;
4
4
  outputTokens: number;
5
+ /** Prompt tokens served from provider cache (subset of inputTokens) */
6
+ cacheReadTokens: number;
7
+ /** Prompt tokens written to provider cache (subset of inputTokens) */
8
+ cacheWriteTokens: number;
9
+ /** cacheReadTokens / inputTokens, 0 when there is no input */
10
+ cacheHitRate: number;
5
11
  totalCost: number;
6
12
  taskCount: number;
7
13
  byModel: Record<string, {
@@ -22,7 +28,10 @@ export declare function initTokenTracker(): void;
22
28
  /**
23
29
  * Track usage from a chat/agentic session (not queue tasks)
24
30
  */
25
- export declare function trackChatUsage(model: string, totalTokens: number, inputTokens?: number, outputTokens?: number): void;
31
+ export declare function trackChatUsage(model: string, totalTokens: number, inputTokens?: number, outputTokens?: number, cache?: {
32
+ cacheReadTokens?: number;
33
+ cacheWriteTokens?: number;
34
+ }): void;
26
35
  /**
27
36
  * Get current usage summary
28
37
  */
@@ -1,5 +1,6 @@
1
1
  import { onTaskEvent } from '../queue/index.js';
2
- import { calculateCost } from './pricing.js';
2
+ import { calculateCost, getModelPricing } from './pricing.js';
3
+ import { cacheHitRate, calculateCachedInputCost, getCacheConfigForModel } from '../providers/prompt-cache.js';
3
4
  import { logger } from '../utils/logger.js';
4
5
  import { recordCost } from './persistence.js';
5
6
  // In-memory usage store
@@ -7,6 +8,9 @@ let globalUsage = {
7
8
  totalTokens: 0,
8
9
  inputTokens: 0,
9
10
  outputTokens: 0,
11
+ cacheReadTokens: 0,
12
+ cacheWriteTokens: 0,
13
+ cacheHitRate: 0,
10
14
  totalCost: 0,
11
15
  taskCount: 0,
12
16
  byModel: {},
@@ -81,16 +85,32 @@ function trackTaskUsage(task, result) {
81
85
  cost,
82
86
  });
83
87
  }
88
+ /**
89
+ * Cost of a chat call. Uncached input bills at the model input rate; cache reads
90
+ * and writes bill at the configurable multipliers of that same rate. Output cost
91
+ * comes from the existing pricing table (calculateCost with zero input).
92
+ */
93
+ function calculateChatCost(model, input, output, cacheRead, cacheWrite) {
94
+ if (cacheRead === 0 && cacheWrite === 0)
95
+ return calculateCost(model, input, output);
96
+ const inputCost = calculateCachedInputCost(input, cacheRead, cacheWrite, getModelPricing(model).inputRate, getCacheConfigForModel(model));
97
+ const outputCost = calculateCost(model, 0, output);
98
+ return Math.round((inputCost + outputCost) * 1_000_000) / 1_000_000;
99
+ }
84
100
  /**
85
101
  * Track usage from a chat/agentic session (not queue tasks)
86
102
  */
87
- export function trackChatUsage(model, totalTokens, inputTokens, outputTokens) {
103
+ export function trackChatUsage(model, totalTokens, inputTokens, outputTokens, cache) {
88
104
  const input = inputTokens ?? Math.floor(totalTokens * 0.7);
89
105
  const output = outputTokens ?? totalTokens - input;
90
- const cost = calculateCost(model, input, output);
106
+ const cacheRead = cache?.cacheReadTokens ?? 0;
107
+ const cacheWrite = cache?.cacheWriteTokens ?? 0;
108
+ const cost = calculateChatCost(model, input, output, cacheRead, cacheWrite);
91
109
  globalUsage.totalTokens += totalTokens;
92
110
  globalUsage.inputTokens += input;
93
111
  globalUsage.outputTokens += output;
112
+ globalUsage.cacheReadTokens += cacheRead;
113
+ globalUsage.cacheWriteTokens += cacheWrite;
94
114
  globalUsage.totalCost += cost;
95
115
  globalUsage.taskCount += 1;
96
116
  const agentId = 'chat';
@@ -116,13 +136,22 @@ export function trackChatUsage(model, totalTokens, inputTokens, outputTokens) {
116
136
  source: 'chat',
117
137
  agentId: 'chat',
118
138
  });
119
- logger.debug('Chat usage tracked', { model, tokens: totalTokens, cost });
139
+ logger.debug('Chat usage tracked', {
140
+ model,
141
+ tokens: totalTokens,
142
+ cost,
143
+ cacheReadTokens: cacheRead,
144
+ cacheWriteTokens: cacheWrite,
145
+ });
120
146
  }
121
147
  /**
122
148
  * Get current usage summary
123
149
  */
124
150
  export function getUsageSummary() {
125
- return { ...globalUsage };
151
+ return {
152
+ ...globalUsage,
153
+ cacheHitRate: cacheHitRate(globalUsage.cacheReadTokens, globalUsage.inputTokens),
154
+ };
126
155
  }
127
156
  /**
128
157
  * Reset usage (for testing or billing cycles)
@@ -132,6 +161,9 @@ export function resetUsage() {
132
161
  totalTokens: 0,
133
162
  inputTokens: 0,
134
163
  outputTokens: 0,
164
+ cacheReadTokens: 0,
165
+ cacheWriteTokens: 0,
166
+ cacheHitRate: 0,
135
167
  totalCost: 0,
136
168
  taskCount: 0,
137
169
  byModel: {},