@occasiolabs/occasio 0.8.4 → 0.8.6

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 (47) hide show
  1. package/README.md +4 -3
  2. package/docs/ADAPTER-STAGE-2-MIGRATION.md +59 -0
  3. package/docs/STAGE-2-STEP-5-SHELL-PLAN.md +107 -0
  4. package/docs/THREAT-MODEL.md +195 -0
  5. package/docs/edr-calibration.md +29 -0
  6. package/package.json +8 -3
  7. package/src/adapters/claude-code.js +1 -2
  8. package/src/adapters/computer-use.js +1 -1
  9. package/src/anomaly/cli.js +4 -1
  10. package/src/anomaly/detectors/deny-rate.js +2 -1
  11. package/src/anomaly/detectors/file-read-volume.js +2 -1
  12. package/src/anomaly/index.js +5 -0
  13. package/src/boundary.js +1 -1
  14. package/src/classifier.js +1 -1
  15. package/src/cli/clear.js +4 -4
  16. package/src/cli/conversation.js +121 -0
  17. package/src/cli/help.js +62 -38
  18. package/src/cli/recap.js +367 -0
  19. package/src/cli/status.js +1 -1
  20. package/src/dashboard.js +2 -3
  21. package/src/demo/audit-demo.js +330 -0
  22. package/src/distiller.js +1 -1
  23. package/src/executor/dispatcher.js +2 -2
  24. package/src/executor/native-handlers/glob.js +173 -0
  25. package/src/executor/native-handlers/grep.js +258 -0
  26. package/src/executor/native-handlers/read.js +99 -0
  27. package/src/executor/native-handlers/todo.js +56 -0
  28. package/src/harness.js +8 -10
  29. package/src/index.js +118 -30
  30. package/src/inspect.js +1 -1
  31. package/src/interceptor.js +9 -29
  32. package/src/ledger.js +2 -3
  33. package/src/mcp-experiment.js +4 -4
  34. package/src/mcp-server.js +3 -3
  35. package/src/policy/doctor.js +2 -2
  36. package/src/policy/engine.js +0 -1
  37. package/src/policy/init.js +1 -1
  38. package/src/policy/loader.js +3 -3
  39. package/src/policy/show.js +1 -2
  40. package/src/preflight/cli.js +0 -1
  41. package/src/preflight/miner.js +3 -6
  42. package/src/redteam.js +1 -2
  43. package/src/replay.js +1 -1
  44. package/src/report/index.js +0 -4
  45. package/src/runtime.js +42 -444
  46. package/src/selftest.js +1 -1
  47. package/src/session.js +1 -1
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * conversation.js — read the last conversation snippet from Claude Code's
5
+ * own per-project session files at ~/.claude/projects/<cwd-encoded>/<id>.jsonl
6
+ *
7
+ * Why: pipeline-events.jsonl captures tool calls, but the actual prompts and
8
+ * model replies live in Claude Code's session files. Those already exist on
9
+ * disk — we don't need to add a new capture path in the proxy.
10
+ *
11
+ * Read-only, no I/O outside ~/.claude. Returns null on any miss (no claude
12
+ * dir, no project dir, no session file, malformed JSONL, etc.) — caller
13
+ * degrades gracefully.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ // Claude Code encodes the cwd by replacing every path separator (':', '\', '/')
21
+ // with a single '-'. So `C:\Users\leona\foo` → `C--Users-leona-foo` because
22
+ // `C` + `:` (-) + `\` (-) + `Users`… yields two dashes between C and Users.
23
+ function encodeCwd(cwd) {
24
+ return cwd.replace(/[:\\/]/g, '-');
25
+ }
26
+
27
+ function findProjectDir(cwd) {
28
+ const root = path.join(os.homedir(), '.claude', 'projects');
29
+ if (!fs.existsSync(root)) return null;
30
+ const encoded = encodeCwd(cwd);
31
+ const candidate = path.join(root, encoded);
32
+ if (fs.existsSync(candidate)) return candidate;
33
+ // Case-insensitive fallback (Windows drive letters): scan once.
34
+ try {
35
+ const entries = fs.readdirSync(root);
36
+ const hit = entries.find(n => n.toLowerCase() === encoded.toLowerCase());
37
+ return hit ? path.join(root, hit) : null;
38
+ } catch { return null; }
39
+ }
40
+
41
+ function listSessionFiles(projectDir) {
42
+ if (!projectDir) return [];
43
+ try {
44
+ return fs.readdirSync(projectDir)
45
+ .filter(n => n.endsWith('.jsonl') && /^[0-9a-f-]{36}\.jsonl$/i.test(n))
46
+ .map(n => {
47
+ const full = path.join(projectDir, n);
48
+ let mtime = 0;
49
+ try { mtime = fs.statSync(full).mtimeMs; } catch { /* */ }
50
+ return { name: n, full, mtime };
51
+ })
52
+ .sort((a, b) => b.mtime - a.mtime);
53
+ } catch { return []; }
54
+ }
55
+
56
+ // Pull a string preview out of message.content, which Claude Code stores as
57
+ // either a raw string or an array of typed blocks.
58
+ function previewText(content, max = 240) {
59
+ if (!content) return '';
60
+ if (typeof content === 'string') return content.slice(0, max).trim();
61
+ if (!Array.isArray(content)) return '';
62
+ for (const block of content) {
63
+ if (!block) continue;
64
+ if (typeof block === 'string') return block.slice(0, max).trim();
65
+ if (block.type === 'text' && typeof block.text === 'string') {
66
+ return block.text.slice(0, max).trim();
67
+ }
68
+ }
69
+ return '';
70
+ }
71
+
72
+ function loadConversation(sessionFile) {
73
+ let text;
74
+ try { text = fs.readFileSync(sessionFile, 'utf8'); }
75
+ catch { return null; }
76
+
77
+ let firstUser = null, lastUser = null, lastAssistant = null;
78
+ let firstTs = null, lastTs = null;
79
+
80
+ for (const line of text.split('\n')) {
81
+ if (!line) continue;
82
+ let row;
83
+ try { row = JSON.parse(line); } catch { continue; }
84
+ if (row.type !== 'user' && row.type !== 'assistant') continue;
85
+ const msg = row.message;
86
+ if (!msg) continue;
87
+ const preview = previewText(msg.content);
88
+ if (!preview) continue;
89
+ const ts = row.timestamp || null;
90
+ if (ts && !firstTs) firstTs = ts;
91
+ if (ts) lastTs = ts;
92
+ if (row.type === 'user') {
93
+ if (!firstUser) firstUser = preview;
94
+ lastUser = preview;
95
+ } else if (row.type === 'assistant') {
96
+ lastAssistant = preview;
97
+ }
98
+ }
99
+
100
+ if (!firstUser && !lastUser && !lastAssistant) return null;
101
+ return { firstUser, lastUser, lastAssistant, firstTs, lastTs };
102
+ }
103
+
104
+ /**
105
+ * Best-effort: find and read the most recent Claude Code conversation for
106
+ * the given cwd. Returns null if none found.
107
+ */
108
+ function readLastConversation(cwd) {
109
+ const dir = findProjectDir(cwd);
110
+ if (!dir) return null;
111
+ const sessions = listSessionFiles(dir);
112
+ if (!sessions.length) return null;
113
+ // Try the newest first; fall through to older if newest is empty.
114
+ for (const s of sessions.slice(0, 3)) {
115
+ const conv = loadConversation(s.full);
116
+ if (conv) return { ...conv, sessionFile: s.full };
117
+ }
118
+ return null;
119
+ }
120
+
121
+ module.exports = { readLastConversation, encodeCwd, findProjectDir, listSessionFiles, loadConversation };
package/src/cli/help.js CHANGED
@@ -1,6 +1,11 @@
1
1
  // `occasio help` — top-level usage. Pure text; no side effects other
2
2
  // than console.log. Each CLI command lives in its own file under
3
3
  // src/cli/ as part of the index.js decomposition (see CHANGELOG).
4
+ //
5
+ // Maturity tags follow the bewertung pillars:
6
+ // (stable) — load-bearing, has test coverage and field validation
7
+ // (beta) — works end-to-end but missing breadth (one detector, one preset)
8
+ // (alpha) — scaffold; needs operator calibration before relying on it
4
9
 
5
10
  'use strict';
6
11
 
@@ -19,42 +24,60 @@ function run() {
19
24
  console.log(`
20
25
  ${col.b(`⚡ Occasio v${VERSION}`)}
21
26
 
22
- ${col.b('Usage:')}
23
- occasio claude [args...] Start Claude with local proxy (intercept + log)
24
- occasio demo 10-second proof: see Occasio block real secrets
25
- occasio demo attest End-to-end attestation pipeline against a synthetic audit chain
26
- occasio demo anomalies End-to-end EDR test: synthetic adversarial chain → all 4 detectors
27
- occasio dashboard Open live dashboard for the running session
28
- occasio register Register shell alias (type 'claude' directly)
29
- occasio status Show session stats and savings breakdown
30
- occasio doctor Check setup: Node, claude CLI, port, Python, profile
31
- occasio clear Reset today's log and session data
32
- occasio clear --history Wipe all historical logs
33
- occasio ledger Inspect token ledger (--last N, --summary, --scope session|today)
34
- occasio replay Replay run audit (--last N, --detail, --run <id>, --attribute)
35
- occasio distill Inspect distilled outputs (--last N, --entry <N> for raw)
36
- occasio inspect Cloud-boundary manifest (--last N, --entry N, --run <id>)
37
- occasio boundary Per-request three-column view: produced / re-entered / prevented
38
- occasio baseline Behavior baseline: [learn|show|compare|reset] (per project cwd)
39
- occasio harness Run a real Claude Code session against scratch fixtures and verify governance claims (needs ANTHROPIC_API_KEY)
40
- occasio redteam Autonomous adversarial test — tester LLM probes a subject Claude Code session under Occasio (needs ANTHROPIC_API_KEY + @anthropic-ai/sdk)
41
- occasio policy [show] Show active policy: flags, tool routing, overrides
42
- occasio policy show --diff Only values that differ from defaults
43
- occasio policy validate Validate policy.yml and report errors/warnings
44
- occasio policy init Create a starter policy.yml (safe, non-destructive)
45
- Use --template strict|finance for a non-default starter
46
- occasio policy doctor Cross-reference session logs with policy; surface suggestions
47
- occasio audit [verify] Verify tamper-evident hash chain in pipeline-events.jsonl
48
- occasio audit repair Truncate a crash-partial trailing line (--file <path> [--dry-run])
49
- occasio report Governance export: file access log, blocked paths, secret events
50
- occasio anomalies Live anomaly detection over the audit chain (--window 15m, --json)
51
- occasio computer-use Apply a Computer-Use policy to a JSONL of tool_use blocks (--dry-run --example)
52
- occasio attest --run-id <uuid> AI-Agent Behavioral Attestation v1: hash-chain commitment + execution summary for one run
53
- Add --sign in GitHub Actions (with permissions: id-token: write) for Sigstore keyless signing
54
- occasio attest verify <file> Re-verify a signed attestation: Sigstore bundle + DSSE payload match + audit chain integrity
55
- occasio selftest Run governance self-checks on a scratch chain (does not touch your audit log)
56
- occasio report --format csv CSV export for auditors / SIEM import
57
- occasio mcp-experiment MCP vs. built-in tool adoption stats (experiment)
27
+ ${col.b('60-Second Start:')}
28
+ ${col.c('occasio init')} Create policy.yml from a template
29
+ ${col.c('occasio register')} Install shell alias so 'claude' uses the proxy
30
+ ${col.c('claude --version')} Confirm the wrapper resolves Claude Code
31
+
32
+ ${col.b('Usage:')} occasio <command> [args...] (or oc <command>)
33
+
34
+ ${col.b('Setup')} ${col.d('— one-time, per project')}
35
+ init ${col.d('(stable)')} Create starter policy.yml (--template strict|finance)
36
+ register ${col.d('(stable)')} Register shell alias (type 'claude' directly)
37
+ doctor ${col.d('(stable)')} Check setup: Node, claude CLI, port, Python, profile
38
+
39
+ ${col.b('Run')} ${col.d('— start a session, observe live state')}
40
+ claude [args...] ${col.d('(stable)')} Start Claude with local proxy (intercept + log)
41
+ status ${col.d('(stable)')} Session stats, savings breakdown, coverage
42
+ clear ${col.d('(stable)')} Reset today's log and session data
43
+ clear --history ${col.d('(stable)')} Wipe all historical logs
44
+ ledger ${col.d('(stable)')} Inspect token ledger (--last N, --summary, --scope)
45
+ dashboard ${col.d('(beta)')} Open live dashboard at http://localhost:3001
46
+
47
+ ${col.b('Inspect')} ${col.d('— forensics over what the agent did')}
48
+ replay ${col.d('(stable)')} Replay run audit (--last N, --detail, --run <id>)
49
+ recap ${col.d('(stable)')} Markdown session summary for agent context (--last N, --run, --format)
50
+ boundary ${col.d('(stable)')} Per-request: produced / re-entered / prevented
51
+ inspect ${col.d('(stable)')} Cloud-boundary manifest (--last N, --entry N)
52
+ distill ${col.d('(stable)')} Inspect distilled outputs (--last N, --entry <N>)
53
+ report ${col.d('(stable)')} Governance export (--format csv for SIEM)
54
+ preflight ${col.d('(beta)')} Read-only miner over past logs
55
+ baseline ${col.d('(beta)')} Behavior baseline: [learn|show|compare|reset]
56
+
57
+ ${col.b('Audit')} ${col.d('— tamper-evidence and attestation')}
58
+ audit verify ${col.d('(stable)')} Verify hash chain in pipeline-events.jsonl
59
+ audit repair ${col.d('(stable)')} Truncate crash-partial trailing line (--file --dry-run)
60
+ attest --run-id <uuid> ${col.d('(stable)')} Behavioral attestation: hash-chain + execution summary
61
+ ${col.d('Add --sign in GitHub Actions for Sigstore keyless signing')}
62
+ attest verify <file> ${col.d('(stable)')} Re-verify signed attestation (bundle + DSSE + chain)
63
+ selftest ${col.d('(stable)')} Run governance self-checks on scratch chain
64
+
65
+ ${col.b('Detect')} ${col.d('— anomalies, adversarial probes')}
66
+ anomalies ${col.d('(beta)')} Windowed EDR over the audit chain (--window 15m --json)
67
+ harness ${col.d('(alpha)')} Real Claude Code run vs. governance claims (API key required)
68
+ redteam ${col.d('(alpha)')} Autonomous adversarial probe (API key + SDK required)
69
+
70
+ ${col.b('Policy & extras')}
71
+ policy [show] ${col.d('(stable)')} Show active policy: flags, routing, overrides
72
+ policy show --diff ${col.d('(stable)')} Only values that differ from defaults
73
+ policy validate ${col.d('(stable)')} Validate policy.yml and report errors/warnings
74
+ policy doctor ${col.d('(beta)')} Cross-reference logs with policy; suggest tightening
75
+ computer-use ${col.d('(alpha)')} Apply policy to a JSONL of tool_use blocks (--dry-run --example)
76
+ mcp-experiment ${col.d('(beta)')} MCP vs. built-in tool adoption stats
77
+ demo audit ${col.d('(stable)')} Auditor scenario: signed attestation + cross-verifier proof
78
+ demo attest ${col.d('(stable)')} End-to-end attestation pipeline against a synthetic chain
79
+ demo anomalies ${col.d('(stable)')} End-to-end EDR test: synthetic adversarial chain
80
+ demo ${col.d('(stable)')} 10-second proof: see Occasio block real secrets
58
81
 
59
82
  ${col.b('Presets:')}
60
83
  --preset balanced (default) Intercept safe reads locally, log all requests
@@ -67,8 +90,9 @@ ${col.b('Flags:')}
67
90
  --block-secrets Alias for --preset strict
68
91
  --log-only Alias for --preset off
69
92
  --dashboard Open live dashboard at http://localhost:3001
70
- --port <N> Proxy port (default: 8081)
71
- --verbose Print live per-request chatter (off by default — quiet for Claude Code's TUI)
93
+ --port <N> Proxy port (default: auto-assigned by OS)
94
+ --recap Print a previous-session banner at startup
95
+ --verbose Print live per-request chatter (off by default)
72
96
 
73
97
  ${col.b('Multi-agent routing:')}
74
98
  Default → Claude Code adapter
@@ -0,0 +1,367 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * occasio recap — markdown summary of a past session, sized to paste into
5
+ * a new Claude prompt so the next agent picks up where the last one left off.
6
+ *
7
+ * Reads from ~/.occasio/pipeline-events.jsonl (read-only). Groups by run_id,
8
+ * picks the most recent session by default. No network, no signing, no policy
9
+ * decisions made — pure read view.
10
+ *
11
+ * Usage:
12
+ * occasio recap # last session, markdown
13
+ * occasio recap --last 3 # last 3 sessions
14
+ * occasio recap --run <id> # specific session
15
+ * occasio recap --format text|json # alt outputs
16
+ *
17
+ * The output is shaped to be useful as agent context — short, factual,
18
+ * decisions-first. Not an audit view (use `occasio replay --detail` for that).
19
+ */
20
+
21
+ const fs = require('fs');
22
+ const path = require('path');
23
+ const os = require('os');
24
+
25
+ const { readLastConversation } = require('./conversation');
26
+
27
+ const CHAIN_FILE = path.join(os.homedir(), '.occasio', 'pipeline-events.jsonl');
28
+
29
+ const col = {
30
+ r: s => `\x1b[31m${s}\x1b[0m`,
31
+ g: s => `\x1b[32m${s}\x1b[0m`,
32
+ y: s => `\x1b[33m${s}\x1b[0m`,
33
+ c: s => `\x1b[36m${s}\x1b[0m`,
34
+ d: s => `\x1b[2m${s}\x1b[0m`,
35
+ b: s => `\x1b[1m${s}\x1b[0m`,
36
+ };
37
+
38
+ // Normalise tool names: chain has both PascalCase (Claude-Code-style) and
39
+ // snake_case (shell-tool aliases via interceptor). Render as the canonical
40
+ // display name so the recap doesn't double-count Read/read_file.
41
+ const TOOL_DISPLAY = {
42
+ read_file: 'Read',
43
+ read: 'Read',
44
+ Read: 'Read',
45
+ grep: 'Grep',
46
+ Grep: 'Grep',
47
+ glob: 'Glob',
48
+ Glob: 'Glob',
49
+ find_files: 'Glob',
50
+ edit_file: 'Edit',
51
+ Edit: 'Edit',
52
+ MultiEdit: 'Edit',
53
+ write_file: 'Write',
54
+ Write: 'Write',
55
+ bash: 'Bash',
56
+ Bash: 'Bash',
57
+ shell_bash: 'Bash',
58
+ shell_powershell: 'PowerShell',
59
+ TodoWrite: 'TodoWrite',
60
+ TodoRead: 'TodoRead',
61
+ WebFetch: 'WebFetch',
62
+ WebSearch: 'WebSearch',
63
+ };
64
+ const TOOLS_THAT_READ = new Set(['Read', 'Grep', 'Glob']);
65
+ const TOOLS_THAT_MODIFY = new Set(['Edit', 'Write']);
66
+
67
+ function normTool(name) { return TOOL_DISPLAY[name] || name || 'unknown'; }
68
+
69
+ function readChain(file) {
70
+ if (!fs.existsSync(file)) return [];
71
+ const text = fs.readFileSync(file, 'utf8');
72
+ const out = [];
73
+ for (const line of text.split('\n')) {
74
+ if (!line) continue;
75
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
76
+ }
77
+ return out;
78
+ }
79
+
80
+ function groupByRun(rows) {
81
+ const map = new Map();
82
+ for (const r of rows) {
83
+ const id = r.run_id || 'legacy';
84
+ if (!map.has(id)) map.set(id, []);
85
+ map.get(id).push(r);
86
+ }
87
+ for (const arr of map.values()) {
88
+ arr.sort((a, b) => (a.ts || '') < (b.ts || '') ? -1 : 1);
89
+ }
90
+ return map;
91
+ }
92
+
93
+ function pickRecentRuns(rowsByRun, n) {
94
+ const entries = [...rowsByRun.entries()].map(([id, rows]) => ({
95
+ id,
96
+ rows,
97
+ end: rows.length ? rows[rows.length - 1].ts || '' : '',
98
+ }));
99
+ entries.sort((a, b) => a.end < b.end ? 1 : -1);
100
+ return entries.slice(0, n);
101
+ }
102
+
103
+ function pathOf(row) {
104
+ const t = row.tool_inputs || {};
105
+ return t.path || t.file_path || t.pattern || t.command || null;
106
+ }
107
+
108
+ function aggregate(rows) {
109
+ const tools = {};
110
+ const readPaths = new Set();
111
+ const modifyPaths = new Set();
112
+ const blocked = [];
113
+ const transformed = [];
114
+ const toolCalls = [];
115
+
116
+ for (const r of rows) {
117
+ if (r.kind !== 'tool_call') continue;
118
+ const tool = normTool(r.tool_name);
119
+ tools[tool] = (tools[tool] || 0) + 1;
120
+ toolCalls.push(r);
121
+
122
+ const p = pathOf(r);
123
+ if (p && r.action !== 'BLOCK') {
124
+ if (TOOLS_THAT_READ.has(tool)) readPaths.add(p);
125
+ if (TOOLS_THAT_MODIFY.has(tool)) modifyPaths.add(p);
126
+ }
127
+ if (r.action === 'BLOCK') {
128
+ blocked.push({ tool, target: p, reason: r.reason || 'policy' });
129
+ }
130
+ if (r.action === 'TRANSFORM') {
131
+ transformed.push({ tool, target: p, redacted: r.secrets_redacted || 0 });
132
+ }
133
+ }
134
+
135
+ return {
136
+ tools, readPaths: [...readPaths], modifyPaths: [...modifyPaths],
137
+ blocked, transformed, toolCalls,
138
+ start: rows[0]?.ts || null,
139
+ end: rows[rows.length - 1]?.ts || null,
140
+ };
141
+ }
142
+
143
+ function shortPath(p, root) {
144
+ if (!p) return '(no path)';
145
+ if (root && p.startsWith(root)) {
146
+ const rel = p.slice(root.length).replace(/^[\\/]+/, '');
147
+ return rel || p;
148
+ }
149
+ return p;
150
+ }
151
+
152
+ function durationStr(start, end) {
153
+ if (!start || !end) return 'unknown';
154
+ const ms = new Date(end) - new Date(start);
155
+ if (!Number.isFinite(ms) || ms < 0) return 'unknown';
156
+ const min = Math.round(ms / 60000);
157
+ if (min < 1) return '<1 min';
158
+ if (min < 60) return min + ' min';
159
+ return Math.floor(min/60) + 'h ' + (min%60) + 'm';
160
+ }
161
+
162
+ function lastActionsLines(toolCalls, root, n = 5) {
163
+ const last = toolCalls.slice(-n);
164
+ return last.map((r, i) => {
165
+ const tool = normTool(r.tool_name);
166
+ const p = shortPath(pathOf(r), root);
167
+ const action = r.action || '';
168
+ const tag = action === 'BLOCK' ? '✗' : action === 'TRANSFORM' ? '⚙' : '·';
169
+ return `${i+1}. ${tag} ${tool} ${p}${action && action !== 'PASS' && action !== 'LOCAL' ? ' → ' + action : ''}`;
170
+ });
171
+ }
172
+
173
+ function renderMarkdown(run, root, conv) {
174
+ const a = aggregate(run.rows);
175
+ const dur = durationStr(a.start, a.end);
176
+ const date = a.start ? new Date(a.start).toISOString().replace('T', ' ').slice(0, 16) : 'unknown';
177
+
178
+ const lines = [];
179
+ lines.push(`# Previous session — ${date}, ${dur}`);
180
+ lines.push('');
181
+
182
+ if (conv && (conv.lastUser || conv.lastAssistant)) {
183
+ lines.push('## Last conversation');
184
+ if (conv.lastUser) {
185
+ lines.push(`- **You said:** ${conv.lastUser}`);
186
+ }
187
+ if (conv.lastAssistant) {
188
+ lines.push(`- **Agent replied:** ${conv.lastAssistant}`);
189
+ }
190
+ lines.push('');
191
+ }
192
+
193
+ if (a.readPaths.length || a.modifyPaths.length) {
194
+ lines.push('## Files touched');
195
+ if (a.modifyPaths.length) {
196
+ const shown = a.modifyPaths.slice(0, 10).map(p => shortPath(p, root));
197
+ const more = a.modifyPaths.length > 10 ? ` (+${a.modifyPaths.length - 10} more)` : '';
198
+ lines.push(`- modified: ${shown.join(', ')}${more}`);
199
+ }
200
+ if (a.readPaths.length) {
201
+ const shown = a.readPaths.slice(0, 10).map(p => shortPath(p, root));
202
+ const more = a.readPaths.length > 10 ? ` (+${a.readPaths.length - 10} more)` : '';
203
+ lines.push(`- read: ${shown.join(', ')}${more}`);
204
+ }
205
+ lines.push('');
206
+ }
207
+
208
+ const toolEntries = Object.entries(a.tools).sort((x, y) => y[1] - x[1]);
209
+ if (toolEntries.length) {
210
+ lines.push('## Tools used');
211
+ lines.push('- ' + toolEntries.map(([k, v]) => `${v} ${k}`).join(' · '));
212
+ lines.push('');
213
+ }
214
+
215
+ if (a.blocked.length || a.transformed.length) {
216
+ lines.push('## Decisions');
217
+ if (a.blocked.length) {
218
+ const byTarget = {};
219
+ for (const b of a.blocked) {
220
+ const key = `${b.tool} ${shortPath(b.target, root)}`;
221
+ byTarget[key] = (byTarget[key] || 0) + 1;
222
+ }
223
+ for (const [k, count] of Object.entries(byTarget)) {
224
+ lines.push(`- ${count}× BLOCK ${k}`);
225
+ }
226
+ }
227
+ if (a.transformed.length) {
228
+ const total = a.transformed.reduce((s, t) => s + (t.redacted || 0), 0);
229
+ lines.push(`- ${a.transformed.length}× TRANSFORM (${total} secret${total === 1 ? '' : 's'} redacted)`);
230
+ }
231
+ lines.push('');
232
+ }
233
+
234
+ if (a.toolCalls.length) {
235
+ lines.push('## Last actions');
236
+ for (const line of lastActionsLines(a.toolCalls, root)) lines.push(line);
237
+ lines.push('');
238
+ }
239
+
240
+ // Open-thread heuristic: surface last Bash/Edit/Write so the next agent
241
+ // knows the most recent state-changing action.
242
+ const lastBash = [...a.toolCalls].reverse().find(r => normTool(r.tool_name) === 'Bash' || normTool(r.tool_name) === 'PowerShell');
243
+ const lastWrite = [...a.toolCalls].reverse().find(r => TOOLS_THAT_MODIFY.has(normTool(r.tool_name)));
244
+ if (lastBash || lastWrite) {
245
+ lines.push('## Open thread (best-guess from last actions)');
246
+ if (lastWrite) {
247
+ lines.push(`- last write: ${normTool(lastWrite.tool_name)} ${shortPath(pathOf(lastWrite), root)}`);
248
+ }
249
+ if (lastBash) {
250
+ const cmd = (lastBash.tool_inputs && (lastBash.tool_inputs.command || lastBash.tool_inputs.cmd)) || '(no cmd)';
251
+ const exit = (typeof lastBash.exit_code === 'number') ? ` (exit ${lastBash.exit_code})` : '';
252
+ lines.push(`- last shell: ${cmd.slice(0, 80)}${cmd.length > 80 ? '…' : ''}${exit}`);
253
+ }
254
+ lines.push('');
255
+ }
256
+
257
+ return lines.join('\n');
258
+ }
259
+
260
+ function renderText(run, root, conv) {
261
+ // Plain text variant, no markdown noise — convenient for CLI piping.
262
+ return renderMarkdown(run, root, conv).replace(/^#+\s*/gm, '').replace(/\n{3,}/g, '\n\n');
263
+ }
264
+
265
+ function renderJson(run, root, conv) {
266
+ const a = aggregate(run.rows);
267
+ return JSON.stringify({
268
+ run_id: run.id,
269
+ started_at: a.start,
270
+ ended_at: a.end,
271
+ duration: durationStr(a.start, a.end),
272
+ conversation: conv ? {
273
+ last_user: conv.lastUser || null,
274
+ last_assistant: conv.lastAssistant || null,
275
+ } : null,
276
+ files: {
277
+ read: a.readPaths.map(p => shortPath(p, root)),
278
+ modified: a.modifyPaths.map(p => shortPath(p, root)),
279
+ },
280
+ tools: a.tools,
281
+ decisions: { blocked: a.blocked, transformed: a.transformed },
282
+ last_actions: a.toolCalls.slice(-5).map(r => ({
283
+ tool: normTool(r.tool_name), action: r.action,
284
+ path: shortPath(pathOf(r), root), ts: r.ts,
285
+ })),
286
+ }, null, 2);
287
+ }
288
+
289
+ function parseArgs(argv) {
290
+ const out = { last: 1, run: null, format: 'md' };
291
+ for (let i = 0; i < argv.length; i++) {
292
+ const a = argv[i];
293
+ if (a === '--last') out.last = Math.max(1, parseInt(argv[++i], 10) || 1);
294
+ else if (a === '--run') out.run = argv[++i];
295
+ else if (a === '--format') out.format = argv[++i];
296
+ else if (a === '--help' || a === '-h') out.help = true;
297
+ }
298
+ return out;
299
+ }
300
+
301
+ function printHelp() {
302
+ process.stdout.write(`
303
+ ${col.b('occasio recap')} ${col.d('— session summary for agent context')}
304
+
305
+ Markdown summary of a past Occasio session: files touched, tools used,
306
+ decisions made, last actions. Designed to paste into a new Claude prompt
307
+ so the next session picks up where the last one left off.
308
+
309
+ ${col.b('Usage')}
310
+ occasio recap last session (markdown)
311
+ occasio recap --last 3 last 3 sessions
312
+ occasio recap --run <id> specific session
313
+ occasio recap --format text|json alternative output
314
+
315
+ ${col.b('Tip')}
316
+ occasio recap | clip ${col.d('(Windows)')}
317
+ occasio recap | pbcopy ${col.d('(macOS)')}
318
+ occasio recap | xclip -sel clip ${col.d('(Linux)')}
319
+
320
+ `);
321
+ }
322
+
323
+ function run(argv) {
324
+ const opts = parseArgs(argv || []);
325
+ if (opts.help) { printHelp(); return 0; }
326
+
327
+ const rows = readChain(CHAIN_FILE);
328
+ if (!rows.length) {
329
+ process.stderr.write(col.d(`No chain at ${CHAIN_FILE} — run \`occasio claude\` first.\n`));
330
+ return 1;
331
+ }
332
+
333
+ const byRun = groupByRun(rows);
334
+ let runs;
335
+ if (opts.run) {
336
+ if (!byRun.has(opts.run)) {
337
+ process.stderr.write(col.r(`No session with run_id ${opts.run}\n`));
338
+ return 1;
339
+ }
340
+ runs = [{ id: opts.run, rows: byRun.get(opts.run) }];
341
+ } else {
342
+ runs = pickRecentRuns(byRun, opts.last);
343
+ }
344
+
345
+ const root = process.cwd();
346
+ // Conversation snippet from Claude Code's own session files for the cwd.
347
+ // Only attach to the most-recent run; older runs in --last N get none
348
+ // (we have no way to map run_id to a specific Claude session reliably).
349
+ const conv = readLastConversation(root);
350
+ const parts = [];
351
+ for (let i = 0; i < runs.length; i++) {
352
+ const r = runs[i];
353
+ const useConv = i === 0 ? conv : null;
354
+ if (opts.format === 'json') {
355
+ parts.push(renderJson(r, root, useConv));
356
+ } else if (opts.format === 'text') {
357
+ parts.push(renderText(r, root, useConv));
358
+ } else {
359
+ parts.push(renderMarkdown(r, root, useConv));
360
+ }
361
+ }
362
+
363
+ process.stdout.write(parts.join('\n\n---\n\n') + '\n');
364
+ return 0;
365
+ }
366
+
367
+ module.exports = { run, aggregate, renderMarkdown, normTool };
package/src/cli/status.js CHANGED
@@ -26,7 +26,7 @@ function todayStr() {
26
26
  function getLogFile() { return path.join(LOG_DIR, 'logs', `${todayStr()}.jsonl`); }
27
27
 
28
28
  function run() {
29
- let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch {}
29
+ let s = null; try { s = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch { /* ignore */ }
30
30
  console.log(col.b('\n⚡ Occasio\n'));
31
31
  if (!s) { console.log(col.d(' No session data yet. Run: occasio claude\n')); return; }
32
32
 
package/src/dashboard.js CHANGED
@@ -16,7 +16,6 @@ const path = require('path');
16
16
  const os = require('os');
17
17
 
18
18
  const DASHBOARD_PORT = 3001;
19
- const PROXY_PORT = 8081;
20
19
  const LOG_DIR = path.join(os.homedir(), '.occasio');
21
20
  const SESSION_FILE = path.join(LOG_DIR, 'session.json');
22
21
 
@@ -97,8 +96,8 @@ const server = http.createServer((req, res) => {
97
96
  }
98
97
 
99
98
  if (req.url === '/api/clear' && req.method === 'POST') {
100
- try { fs.writeFileSync(todayLogFile(), ''); } catch {}
101
- try { fs.writeFileSync(SESSION_FILE, '{}'); } catch {}
99
+ try { fs.writeFileSync(todayLogFile(), ''); } catch { /* ignore */ }
100
+ try { fs.writeFileSync(SESSION_FILE, '{}'); } catch { /* ignore */ }
102
101
  res.writeHead(200, { 'Content-Type': 'application/json' });
103
102
  res.end('{"ok":true}');
104
103
  broadcast({ type: 'update', session: {}, entries: [] });