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,227 @@
1
+ /**
2
+ * Receipt viewer: a terminal summary and a self-contained single-file HTML page
3
+ * (inline CSS and JS, no external assets). Every piece of receipt data is
4
+ * HTML-escaped, and a CSP meta tag blocks anything not inline.
5
+ */
6
+ import { createHash } from 'node:crypto';
7
+ export function buildReceiptModel(events) {
8
+ const model = { attempts: [], files: [] };
9
+ const byAttempt = new Map();
10
+ const view = (n) => {
11
+ let a = byAttempt.get(n);
12
+ if (!a) {
13
+ a = { attempt: n, toolCalls: [], rollbacks: [] };
14
+ byAttempt.set(n, a);
15
+ model.attempts.push(a);
16
+ }
17
+ return a;
18
+ };
19
+ for (const e of events) {
20
+ switch (e.type) {
21
+ case 'run_start':
22
+ model.start = e.data;
23
+ model.startedAt = e.ts;
24
+ break;
25
+ case 'attempt_start':
26
+ view(e.data.attempt).model = e.data.model;
27
+ break;
28
+ case 'tool_call':
29
+ view(e.data.attempt).toolCalls.push(e.data);
30
+ break;
31
+ case 'file_change':
32
+ model.files.push(e.data);
33
+ break;
34
+ case 'verifier_result':
35
+ if (e.data.attempt === 0)
36
+ model.baseline = e.data;
37
+ else
38
+ view(e.data.attempt).verifier = e.data;
39
+ break;
40
+ case 'usage': {
41
+ const a = view(e.data.attempt);
42
+ a.usage = e.data;
43
+ if (!a.model && e.data.model)
44
+ a.model = e.data.model;
45
+ break;
46
+ }
47
+ case 'rollback':
48
+ view(e.data.attempt).rollbacks.push(e.data);
49
+ break;
50
+ case 'attempt_end':
51
+ view(e.data.attempt).end = e.data;
52
+ break;
53
+ case 'run_end':
54
+ model.end = e.data;
55
+ model.endedAt = e.ts;
56
+ break;
57
+ }
58
+ }
59
+ return model;
60
+ }
61
+ const usd = (n) => `$${n.toFixed(4)}`;
62
+ // Terminal
63
+ export function renderTerminalSummary(events, verify) {
64
+ const m = buildReceiptModel(events);
65
+ const out = [];
66
+ if (verify)
67
+ out.push(`Integrity: ${verify.ok ? 'OK' : 'FAILED'}. ${verify.message}`, '');
68
+ if (m.start) {
69
+ out.push(`Goal: ${m.start.goal}`);
70
+ out.push(`Verifier: ${m.start.verifyCommand}`);
71
+ out.push(`Branch: ${m.start.branch}${m.start.repoHead ? ` (from ${m.start.repoHead.slice(0, 12)})` : ''}`);
72
+ out.push(`Tool: ${m.start.tool.name} ${m.start.tool.version}`);
73
+ }
74
+ if (m.end) {
75
+ out.push(`Result: ${m.end.verified ? 'VERIFIED' : 'NOT VERIFIED'} (${m.end.stopReason}), ${m.end.totals.attempts} attempt(s), ${m.end.totals.totalTokens} tokens, ${usd(m.end.totals.costUsd)}`);
76
+ }
77
+ else {
78
+ out.push('Result: incomplete (no run_end)');
79
+ }
80
+ if (m.baseline)
81
+ out.push(`Baseline: ${m.baseline.passed ? 'pass' : `fail (score ${m.baseline.failureScore})`}`);
82
+ out.push('');
83
+ for (const a of m.attempts) {
84
+ const bits = [`Attempt ${a.attempt}: ${a.end?.outcome ?? 'unfinished'}`];
85
+ if (a.model)
86
+ bits.push(a.model);
87
+ if (a.verifier)
88
+ bits.push(`verifier ${a.verifier.passed ? 'pass' : `fail (score ${a.verifier.failureScore})`}`);
89
+ if (a.toolCalls.length)
90
+ bits.push(`${a.toolCalls.length} tool call(s)`);
91
+ if (a.usage)
92
+ bits.push(`${a.usage.totalTokens} tokens, ${usd(a.usage.costUsd)}`);
93
+ for (const r of a.rollbacks)
94
+ bits.push(`rolled back: ${r.reason}`);
95
+ out.push(bits.join(', '));
96
+ }
97
+ if (m.files.length) {
98
+ out.push('', 'Files changed:');
99
+ for (const f of m.files)
100
+ out.push(` ${f.status} ${f.path}`);
101
+ }
102
+ return out.join('\n') + '\n';
103
+ }
104
+ // HTML
105
+ export function escapeHtml(input) {
106
+ return input
107
+ .replace(/&/g, '&')
108
+ .replace(/</g, '&lt;')
109
+ .replace(/>/g, '&gt;')
110
+ .replace(/"/g, '&quot;')
111
+ .replace(/'/g, '&#39;');
112
+ }
113
+ const CSS = `
114
+ :root{color-scheme:light dark;--bg:#fff;--fg:#1a1a1a;--mut:#666;--line:#ddd;--ok:#127a3a;--bad:#b3261e;--warn:#8a5a00;--code:#f5f5f5}
115
+ @media(prefers-color-scheme:dark){:root{--bg:#141414;--fg:#e8e8e8;--mut:#9a9a9a;--line:#333;--ok:#5fd08a;--bad:#ff8a80;--warn:#e0b050;--code:#1e1e1e}}
116
+ body{font:14px/1.5 system-ui,sans-serif;background:var(--bg);color:var(--fg);margin:0 auto;padding:16px;max-width:960px}
117
+ h1{font-size:20px;margin:0 0 4px}h2{font-size:16px;margin:24px 0 8px}
118
+ .mut{color:var(--mut)}.ok{color:var(--ok)}.bad{color:var(--bad)}.warn{color:var(--warn)}
119
+ .card{border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin:8px 0}
120
+ .row{display:flex;flex-wrap:wrap;gap:4px 16px}
121
+ pre{background:var(--code);padding:8px;overflow:auto;border-radius:4px;margin:6px 0;font:12px/1.4 ui-monospace,monospace;white-space:pre-wrap;word-break:break-word}
122
+ .add{color:var(--ok)}.del{color:var(--bad)}.hunk{color:var(--mut)}
123
+ details>summary{cursor:pointer}
124
+ table{border-collapse:collapse;width:100%}td,th{text-align:left;padding:2px 8px 2px 0;border-bottom:1px solid var(--line);font-size:13px}
125
+ button{font:inherit;padding:2px 8px}
126
+ `;
127
+ const JS = `
128
+ document.getElementById('expand').addEventListener('click',function(){
129
+ var open=this.getAttribute('data-open')!=='1';
130
+ document.querySelectorAll('details').forEach(function(d){d.open=open});
131
+ this.setAttribute('data-open',open?'1':'0');
132
+ this.textContent=open?'Collapse all':'Expand all';
133
+ });
134
+ `;
135
+ const cspHash = (s) => `'sha256-${createHash('sha256').update(s, 'utf-8').digest('base64')}'`;
136
+ function diffHtml(diff) {
137
+ return diff
138
+ .split('\n')
139
+ .map((line) => {
140
+ const cls = line.startsWith('+') && !line.startsWith('+++')
141
+ ? 'add'
142
+ : line.startsWith('-') && !line.startsWith('---')
143
+ ? 'del'
144
+ : line.startsWith('@@')
145
+ ? 'hunk'
146
+ : '';
147
+ const text = escapeHtml(line);
148
+ return cls ? `<span class="${cls}">${text}</span>` : text;
149
+ })
150
+ .join('\n');
151
+ }
152
+ function verifierHtml(v, label) {
153
+ const cls = v.passed ? 'ok' : 'bad';
154
+ return `<details><summary>${escapeHtml(label)}: <span class="${cls}">${v.passed ? 'PASS' : 'FAIL'}</span>, exit ${v.exitCode === null ? 'n/a' : escapeHtml(String(v.exitCode))}, score ${escapeHtml(String(v.failureScore))}${v.timedOut ? ', timed out' : ''}</summary><pre>${escapeHtml(v.output)}</pre></details>`;
155
+ }
156
+ export function renderHtml(events, verify) {
157
+ const m = buildReceiptModel(events);
158
+ const p = [];
159
+ const title = m.start ? `Run receipt: ${m.start.goal}` : 'Run receipt';
160
+ p.push(`<h1>${escapeHtml(title.length > 120 ? `${title.slice(0, 117)}...` : title)}</h1>`);
161
+ if (verify) {
162
+ p.push(`<div class="card"><strong class="${verify.ok ? 'ok' : 'bad'}">Integrity: ${verify.ok ? 'OK' : 'FAILED'}</strong> <span class="mut">${escapeHtml(verify.message)}</span>${verify.signature.present ? ` <span class="${verify.signature.valid ? 'ok' : 'bad'}">signature ${verify.signature.valid ? 'valid' : 'invalid'}</span>` : ' <span class="mut">unsigned</span>'}${verify.signature.publicKey ? `<div class="mut">key ${escapeHtml(verify.signature.publicKey.slice(-24))}</div>` : ''}${verify.lastHash ? `<div class="mut">last hash ${escapeHtml(verify.lastHash)}</div>` : ''}</div>`);
163
+ }
164
+ else {
165
+ p.push('<div class="card mut">Integrity was not checked for this view.</div>');
166
+ }
167
+ if (m.start) {
168
+ p.push(`<div class="card"><div class="row"><span>Verifier <code>${escapeHtml(m.start.verifyCommand)}</code></span><span>Branch <code>${escapeHtml(m.start.branch)}</code></span>` +
169
+ `<span>Head <code>${escapeHtml(m.start.repoHead ?? 'unknown')}</code></span><span>${escapeHtml(m.start.tool.name)} ${escapeHtml(m.start.tool.version)}</span></div></div>`);
170
+ }
171
+ if (m.end) {
172
+ p.push(`<div class="card"><strong class="${m.end.verified ? 'ok' : 'bad'}">${m.end.verified ? 'VERIFIED' : 'NOT VERIFIED'}</strong> (${escapeHtml(m.end.stopReason)})` +
173
+ `<div class="row mut"><span>${escapeHtml(String(m.end.totals.attempts))} attempt(s)</span><span>${escapeHtml(String(m.end.totals.totalTokens))} tokens</span>` +
174
+ `<span>cost ${escapeHtml(usd(m.end.totals.costUsd))}</span><span>${escapeHtml(String(m.end.totals.filesChanged))} file(s) changed</span></div></div>`);
175
+ }
176
+ else {
177
+ p.push('<div class="card warn">Incomplete: no run_end event.</div>');
178
+ }
179
+ p.push('<h2>Timeline</h2><button id="expand" type="button" data-open="0">Expand all</button>');
180
+ if (m.baseline)
181
+ p.push(`<div class="card">${verifierHtml(m.baseline, 'Baseline (before any attempt)')}</div>`);
182
+ for (const a of m.attempts) {
183
+ const outcome = a.end?.outcome ?? 'unfinished';
184
+ const cls = outcome === 'verified' ? 'ok' : outcome === 'improved' || outcome === 'unchanged' ? 'warn' : 'bad';
185
+ const head = [`<strong>Attempt ${escapeHtml(String(a.attempt))}</strong>`, `<span class="${cls}">${escapeHtml(outcome)}</span>`];
186
+ if (a.model)
187
+ head.push(`<span class="mut">${escapeHtml(a.model)}</span>`);
188
+ if (a.usage)
189
+ head.push(`<span class="mut">${escapeHtml(String(a.usage.totalTokens))} tokens, ${escapeHtml(usd(a.usage.costUsd))}</span>`);
190
+ const body = [];
191
+ if (a.toolCalls.length) {
192
+ body.push(`<details><summary>${escapeHtml(String(a.toolCalls.length))} tool call(s)</summary><table><tr><th>Tool</th><th>Args</th><th>ms</th><th>ok</th></tr>` +
193
+ a.toolCalls
194
+ .map((t) => `<tr><td>${escapeHtml(t.name)}</td><td>${escapeHtml(t.argsSummary)}${t.redacted ? ' <span class="warn">(redacted)</span>' : ''}</td><td>${escapeHtml(String(Math.round(t.durationMs)))}</td><td class="${t.ok ? 'ok' : 'bad'}">${t.ok ? 'yes' : 'no'}</td></tr>`)
195
+ .join('') +
196
+ '</table></details>');
197
+ }
198
+ if (a.verifier)
199
+ body.push(verifierHtml(a.verifier, 'Verifier'));
200
+ for (const r of a.rollbacks)
201
+ body.push(`<div class="bad">Rolled back: ${escapeHtml(r.reason)}</div>`);
202
+ p.push(`<div class="card"><div class="row">${head.join('')}</div>${body.join('')}</div>`);
203
+ }
204
+ p.push('<h2>Diff</h2>');
205
+ if (m.files.length === 0)
206
+ p.push('<div class="mut">No files changed.</div>');
207
+ for (const f of m.files) {
208
+ const label = `${escapeHtml(f.status)} <code>${escapeHtml(f.path)}</code>`;
209
+ const detail = f.diff !== undefined
210
+ ? `<pre>${diffHtml(f.diff)}</pre>`
211
+ : `<div class="mut">Diff omitted. sha256 ${escapeHtml(f.diffSha256)}</div>`;
212
+ p.push(`<div class="card"><details open><summary>${label}${f.diffTruncated ? ' <span class="warn">(truncated)</span>' : ''}</summary>${detail}</details></div>`);
213
+ }
214
+ const csp = `default-src 'none'; style-src ${cspHash(CSS)}; script-src ${cspHash(JS)}; base-uri 'none'; form-action 'none'`;
215
+ return [
216
+ '<!doctype html>',
217
+ '<html lang="en"><head><meta charset="utf-8">',
218
+ `<meta http-equiv="Content-Security-Policy" content="${escapeHtml(csp)}">`,
219
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
220
+ `<title>${escapeHtml(title.slice(0, 120))}</title>`,
221
+ `<style>${CSS}</style></head><body>`,
222
+ p.join('\n'),
223
+ `<script>${JS}</script></body></html>`,
224
+ '',
225
+ ].join('\n');
226
+ }
227
+ //# sourceMappingURL=receipt-view.js.map
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Receipt writer: append-only JSONL, canonical JSON, SHA-256 hash chaining,
3
+ * secret redaction before hashing, optional Ed25519 signature on run_end.
4
+ * Spec: docs/specs/run-receipt-v0.md
5
+ */
6
+ import type { ReceiptDataMap, ReceiptEvent, ReceiptEventType, ReceiptSink } from './receipt-types.js';
7
+ /** Sorted keys, no whitespace, `undefined` members dropped, non-finite numbers rejected. */
8
+ export declare function canonicalJson(value: unknown): string;
9
+ export declare function sha256Hex(input: string | Uint8Array): string;
10
+ /** Hash of an event: canonical JSON without `hash` and `signature`. */
11
+ export declare function computeEventHash(event: Record<string, unknown>): string;
12
+ export declare const REDACTED = "[REDACTED]";
13
+ export declare function redactString(input: string): string;
14
+ /** Deep-redacts strings and values under secret-looking object keys. Returns the changed flag. */
15
+ export declare function redactValue(value: unknown): {
16
+ value: unknown;
17
+ changed: boolean;
18
+ };
19
+ export interface ReceiptSigner {
20
+ publicKey: string;
21
+ sign(hash: string): string;
22
+ }
23
+ /** Signer from a PKCS8 PEM Ed25519 private key. */
24
+ export declare function createSigner(privateKeyPem: string): ReceiptSigner;
25
+ export declare function loadSignerFromFile(path: string): ReceiptSigner;
26
+ export interface ReceiptWriterOptions {
27
+ path: string;
28
+ signer?: ReceiptSigner;
29
+ /** Default true. Only disable for tests of the raw chain. */
30
+ redact?: boolean;
31
+ now?: () => Date;
32
+ }
33
+ /** Default location: <projectRoot>/.profclaw/runs/<runId>/receipt.jsonl (env PROFCLAW_RECEIPT_PATH overrides). */
34
+ export declare function defaultReceiptPath(projectRoot: string, runId: string, env?: NodeJS.ProcessEnv): string;
35
+ export declare class ReceiptWriter implements ReceiptSink {
36
+ readonly path: string;
37
+ private seq;
38
+ private prev;
39
+ private ended;
40
+ private readonly signer?;
41
+ private readonly redact;
42
+ private readonly now;
43
+ constructor(options: ReceiptWriterOptions);
44
+ get lastHash(): string;
45
+ append<K extends ReceiptEventType>(type: K, data: ReceiptDataMap[K]): ReceiptEvent;
46
+ }
47
+ //# sourceMappingURL=receipt-writer.d.ts.map
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Receipt writer: append-only JSONL, canonical JSON, SHA-256 hash chaining,
3
+ * secret redaction before hashing, optional Ed25519 signature on run_end.
4
+ * Spec: docs/specs/run-receipt-v0.md
5
+ */
6
+ import { createHash, createPrivateKey, createPublicKey, sign as edSign } from 'node:crypto';
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { GENESIS_HASH, RECEIPT_VERSION } from './receipt-types.js';
10
+ // Canonical JSON
11
+ /** Sorted keys, no whitespace, `undefined` members dropped, non-finite numbers rejected. */
12
+ export function canonicalJson(value) {
13
+ if (value === null)
14
+ return 'null';
15
+ switch (typeof value) {
16
+ case 'string':
17
+ case 'boolean':
18
+ return JSON.stringify(value);
19
+ case 'number':
20
+ if (!Number.isFinite(value))
21
+ throw new Error('canonicalJson: non-finite number');
22
+ return JSON.stringify(value);
23
+ case 'object': {
24
+ if (Array.isArray(value)) {
25
+ return `[${value.map((v) => (v === undefined ? 'null' : canonicalJson(v))).join(',')}]`;
26
+ }
27
+ const obj = value;
28
+ const parts = [];
29
+ for (const key of Object.keys(obj).sort()) {
30
+ if (obj[key] === undefined)
31
+ continue;
32
+ parts.push(`${JSON.stringify(key)}:${canonicalJson(obj[key])}`);
33
+ }
34
+ return `{${parts.join(',')}}`;
35
+ }
36
+ default:
37
+ throw new Error(`canonicalJson: unsupported type ${typeof value}`);
38
+ }
39
+ }
40
+ export function sha256Hex(input) {
41
+ return createHash('sha256').update(input).digest('hex');
42
+ }
43
+ /** Hash of an event: canonical JSON without `hash` and `signature`. */
44
+ export function computeEventHash(event) {
45
+ const rest = { ...event };
46
+ delete rest['hash'];
47
+ delete rest['signature'];
48
+ return sha256Hex(canonicalJson(rest));
49
+ }
50
+ // Redaction
51
+ export const REDACTED = '[REDACTED]';
52
+ const SECRET_NAME = /(KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL)/i;
53
+ const TOKEN_PATTERNS = [
54
+ /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
55
+ /\bsk-[A-Za-z0-9_-]{16,}/g,
56
+ /\bgh[pousr]_[A-Za-z0-9]{20,}/g,
57
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
58
+ /\bxox[abprs]-[A-Za-z0-9-]{10,}/g,
59
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g,
60
+ /\bAIza[0-9A-Za-z_-]{30,}/g,
61
+ /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g,
62
+ /\bnpm_[A-Za-z0-9]{30,}/g,
63
+ ];
64
+ const BEARER = /\b(Bearer\s+)[A-Za-z0-9._~+/=-]{12,}/gi;
65
+ /** NAME=value or NAME: value, where NAME looks like a secret variable. */
66
+ const ENV_ASSIGN = /\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIALS?)[A-Z0-9_]*)(\s*[=:]\s*)("[^"\n]*"|'[^'\n]*'|[^\s"',;]+)/g;
67
+ export function redactString(input) {
68
+ let out = input;
69
+ for (const re of TOKEN_PATTERNS)
70
+ out = out.replace(re, REDACTED);
71
+ out = out.replace(BEARER, `$1${REDACTED}`);
72
+ out = out.replace(ENV_ASSIGN, (match, name, sep, value) => value === REDACTED || value === `"${REDACTED}"` ? match : `${name}${sep}${REDACTED}`);
73
+ return out;
74
+ }
75
+ /** Deep-redacts strings and values under secret-looking object keys. Returns the changed flag. */
76
+ export function redactValue(value) {
77
+ if (typeof value === 'string') {
78
+ const next = redactString(value);
79
+ return { value: next, changed: next !== value };
80
+ }
81
+ if (Array.isArray(value)) {
82
+ let changed = false;
83
+ const out = value.map((item) => {
84
+ const r = redactValue(item);
85
+ changed = changed || r.changed;
86
+ return r.value;
87
+ });
88
+ return { value: out, changed };
89
+ }
90
+ if (value !== null && typeof value === 'object') {
91
+ let changed = false;
92
+ const out = {};
93
+ for (const [key, v] of Object.entries(value)) {
94
+ if (SECRET_NAME.test(key) && typeof v === 'string' && v !== '' && v !== REDACTED) {
95
+ out[key] = REDACTED;
96
+ changed = true;
97
+ continue;
98
+ }
99
+ const r = redactValue(v);
100
+ changed = changed || r.changed;
101
+ out[key] = r.value;
102
+ }
103
+ return { value: out, changed };
104
+ }
105
+ return { value, changed: false };
106
+ }
107
+ /** Signer from a PKCS8 PEM Ed25519 private key. */
108
+ export function createSigner(privateKeyPem) {
109
+ const privateKey = createPrivateKey(privateKeyPem);
110
+ if (privateKey.asymmetricKeyType !== 'ed25519')
111
+ throw new Error('Receipt signing key must be Ed25519');
112
+ const publicKey = createPublicKey(privateKey).export({ type: 'spki', format: 'der' }).toString('base64');
113
+ return {
114
+ publicKey,
115
+ sign: (hash) => edSign(null, Buffer.from(hash, 'utf-8'), privateKey).toString('base64'),
116
+ };
117
+ }
118
+ export function loadSignerFromFile(path) {
119
+ return createSigner(readFileSync(path, 'utf-8'));
120
+ }
121
+ /** Default location: <projectRoot>/.profclaw/runs/<runId>/receipt.jsonl (env PROFCLAW_RECEIPT_PATH overrides). */
122
+ export function defaultReceiptPath(projectRoot, runId, env = process.env) {
123
+ const override = env['PROFCLAW_RECEIPT_PATH']?.trim();
124
+ return override ? override : join(projectRoot, '.profclaw', 'runs', runId, 'receipt.jsonl');
125
+ }
126
+ export class ReceiptWriter {
127
+ path;
128
+ seq = 0;
129
+ prev = GENESIS_HASH;
130
+ ended = false;
131
+ signer;
132
+ redact;
133
+ now;
134
+ constructor(options) {
135
+ this.path = options.path;
136
+ this.signer = options.signer;
137
+ this.redact = options.redact ?? true;
138
+ this.now = options.now ?? (() => new Date());
139
+ mkdirSync(dirname(this.path), { recursive: true });
140
+ // Append-only and one receipt per file: refuse to extend an existing file.
141
+ if (existsSync(this.path))
142
+ throw new Error(`Receipt already exists: ${this.path}`);
143
+ writeFileSync(this.path, '', { flag: 'wx' });
144
+ }
145
+ get lastHash() {
146
+ return this.prev;
147
+ }
148
+ append(type, data) {
149
+ if (this.ended)
150
+ throw new Error('Receipt already ended (run_end written)');
151
+ let payload = data;
152
+ if (this.redact) {
153
+ const r = redactValue(data);
154
+ payload = r.value;
155
+ if (type === 'tool_call' && r.changed) {
156
+ payload['redacted'] = true;
157
+ }
158
+ }
159
+ const base = {
160
+ v: RECEIPT_VERSION,
161
+ seq: this.seq,
162
+ ts: this.now().toISOString(),
163
+ type,
164
+ data: payload,
165
+ prev: this.prev,
166
+ };
167
+ const hash = computeEventHash(base);
168
+ const event = { ...base, hash };
169
+ if (type === 'run_end' && this.signer) {
170
+ const signature = {
171
+ alg: 'ed25519',
172
+ publicKey: this.signer.publicKey,
173
+ sig: this.signer.sign(hash),
174
+ };
175
+ event['signature'] = signature;
176
+ }
177
+ // Canonical text per line; hash and signature are members like any other.
178
+ appendFileSync(this.path, `${canonicalJson(event)}\n`, 'utf-8');
179
+ this.seq++;
180
+ this.prev = hash;
181
+ if (type === 'run_end')
182
+ this.ended = true;
183
+ return event;
184
+ }
185
+ }
186
+ //# sourceMappingURL=receipt-writer.js.map
@@ -32,6 +32,12 @@ export declare class ResultStore {
32
32
  * Delete all temp files created during this session.
33
33
  */
34
34
  cleanup(): Promise<void>;
35
+ /**
36
+ * Remove stale result directories (e.g. from crashed runs) older than the TTL.
37
+ * TTL comes from PROFCLAW_RESULT_STORE_TTL_MS (default 24h). Returns the
38
+ * number of directories removed. Never throws.
39
+ */
40
+ static sweepStale(ttlMs?: number, baseDir?: string, now?: number): Promise<number>;
35
41
  private writeTempFile;
36
42
  }
37
43
  //# sourceMappingURL=result-store.d.ts.map
@@ -5,7 +5,7 @@
5
5
  * inline size limit. This prevents context windows from being flooded by
6
6
  * large tool outputs (e.g. file reads, API dumps).
7
7
  */
8
- import { writeFile, unlink, mkdir } from "node:fs/promises";
8
+ import { writeFile, unlink, mkdir, readdir, rm, stat } from "node:fs/promises";
9
9
  import { tmpdir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { randomUUID } from "node:crypto";
@@ -16,11 +16,17 @@ const MAX_INLINE_SIZE = 50_000;
16
16
  const MAX_TOTAL_SIZE = 5_000_000;
17
17
  /** Number of chars to include in the inline preview */
18
18
  const PREVIEW_LENGTH = 500;
19
+ const TEMP_DIR_PREFIX = "profclaw-results-";
20
+ /** Stale temp dir TTL, env-configurable (PROFCLAW_RESULT_STORE_TTL_MS, default 24h) */
21
+ function getResultStoreTtlMs() {
22
+ const parsed = Number.parseInt(process.env.PROFCLAW_RESULT_STORE_TTL_MS ?? "", 10);
23
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 24 * 60 * 60 * 1000;
24
+ }
19
25
  export class ResultStore {
20
26
  results = new Map();
21
27
  tempDir;
22
28
  constructor(sessionId) {
23
- this.tempDir = join(tmpdir(), `profclaw-results-${sessionId}`);
29
+ this.tempDir = join(tmpdir(), `${TEMP_DIR_PREFIX}${sessionId}`);
24
30
  }
25
31
  /**
26
32
  * Store a tool result. Small results stay inline; large results spill to
@@ -93,6 +99,41 @@ export class ResultStore {
93
99
  count: deletions.length,
94
100
  });
95
101
  }
102
+ /**
103
+ * Remove stale result directories (e.g. from crashed runs) older than the TTL.
104
+ * TTL comes from PROFCLAW_RESULT_STORE_TTL_MS (default 24h). Returns the
105
+ * number of directories removed. Never throws.
106
+ */
107
+ static async sweepStale(ttlMs = getResultStoreTtlMs(), baseDir = tmpdir(), now = Date.now()) {
108
+ let removed = 0;
109
+ try {
110
+ const entries = await readdir(baseDir);
111
+ for (const name of entries) {
112
+ if (!name.startsWith(TEMP_DIR_PREFIX))
113
+ continue;
114
+ const dir = join(baseDir, name);
115
+ try {
116
+ const info = await stat(dir);
117
+ if (!info.isDirectory() || now - info.mtimeMs <= ttlMs)
118
+ continue;
119
+ await rm(dir, { recursive: true, force: true });
120
+ removed++;
121
+ }
122
+ catch (err) {
123
+ logger.warn("[ResultStore] Failed to sweep stale dir", {
124
+ dir,
125
+ error: err instanceof Error ? err.message : String(err),
126
+ });
127
+ }
128
+ }
129
+ }
130
+ catch (err) {
131
+ logger.warn("[ResultStore] Stale sweep failed", {
132
+ error: err instanceof Error ? err.message : String(err),
133
+ });
134
+ }
135
+ return removed;
136
+ }
96
137
  async writeTempFile(toolCallId, content) {
97
138
  await mkdir(this.tempDir, { recursive: true });
98
139
  const fileName = `${toolCallId}-${randomUUID()}.json`;
@@ -40,4 +40,10 @@ export declare class SessionDiffTracker {
40
40
  * Returns the process-level singleton SessionDiffTracker.
41
41
  */
42
42
  export declare function getSessionDiffTracker(): SessionDiffTracker;
43
+ /**
44
+ * Produce a minimal edit script using the Myers diff algorithm.
45
+ * Returns an array of [type, line] tuples where type is '+', '-', or '='.
46
+ */
47
+ /** Myers shortest edit script. Exported for tests. */
48
+ export declare function shortestEditScript(oldLines: string[], newLines: string[]): Array<['+' | '-' | '=', string]>;
43
49
  //# sourceMappingURL=session-diff.d.ts.map
@@ -176,7 +176,8 @@ function renderHunk(hunk) {
176
176
  * Produce a minimal edit script using the Myers diff algorithm.
177
177
  * Returns an array of [type, line] tuples where type is '+', '-', or '='.
178
178
  */
179
- function shortestEditScript(oldLines, newLines) {
179
+ /** Myers shortest edit script. Exported for tests. */
180
+ export function shortestEditScript(oldLines, newLines) {
180
181
  const n = oldLines.length;
181
182
  const m = newLines.length;
182
183
  const max = n + m;
@@ -211,38 +212,28 @@ function shortestEditScript(oldLines, newLines) {
211
212
  const edits = [];
212
213
  let x = n;
213
214
  let y = m;
214
- for (let d = trace.length - 1; d >= 0 && (x > 0 || y > 0); d--) {
215
+ for (let d = trace.length - 1; d >= 0; d--) {
215
216
  const vPrev = trace[d];
216
217
  const k = x - y;
217
- const ki = k + max;
218
- let prevK;
219
- if (k === -d || (k !== d && vPrev[ki - 1] < vPrev[ki + 1])) {
220
- prevK = k + 1;
221
- }
222
- else {
223
- prevK = k - 1;
224
- }
218
+ const down = k === -d || (k !== d && vPrev[k - 1 + max] < vPrev[k + 1 + max]);
219
+ const prevK = down ? k + 1 : k - 1;
225
220
  const prevX = vPrev[prevK + max];
226
221
  const prevY = prevX - prevK;
227
- while (x > prevX + 1 && y > prevY + 1) {
222
+ // Follow the diagonal (matching lines) back to where this round's edit ended.
223
+ while (x > prevX && y > prevY) {
228
224
  edits.unshift(['=', oldLines[x - 1]]);
229
225
  x--;
230
226
  y--;
231
227
  }
232
228
  if (d > 0) {
233
- if (x > prevX && y > prevY) {
234
- edits.unshift(['=', oldLines[x - 1]]);
235
- x--;
229
+ if (down) {
230
+ edits.unshift(['+', newLines[y - 1]]);
236
231
  y--;
237
232
  }
238
- else if (x > prevX) {
233
+ else {
239
234
  edits.unshift(['-', oldLines[x - 1]]);
240
235
  x--;
241
236
  }
242
- else if (y > prevY) {
243
- edits.unshift(['+', newLines[y - 1]]);
244
- y--;
245
- }
246
237
  }
247
238
  }
248
239
  while (x > 0) {