oco-claude-plugin 0.1.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.
- package/LICENSE +189 -0
- package/README.md +65 -0
- package/cli.mjs +413 -0
- package/package.json +28 -0
- package/plugin/agents/codebase-investigator.md +63 -0
- package/plugin/agents/patch-verifier.md +68 -0
- package/plugin/agents/refactor-reviewer.md +81 -0
- package/plugin/hooks/lib/utils.mjs +109 -0
- package/plugin/hooks/post-tool-use.mjs +88 -0
- package/plugin/hooks/pre-tool-use.mjs +100 -0
- package/plugin/hooks/stop.mjs +98 -0
- package/plugin/hooks/user-prompt-submit.cjs +61 -0
- package/plugin/mcp/bridge.js +434 -0
- package/plugin/settings-fragment.json +64 -0
- package/plugin/skills/oco-inspect-repo-area/SKILL.md +61 -0
- package/plugin/skills/oco-investigate-bug/SKILL.md +71 -0
- package/plugin/skills/oco-safe-refactor/SKILL.md +81 -0
- package/plugin/skills/oco-trace-stack/SKILL.md +64 -0
- package/plugin/skills/oco-verify-fix/SKILL.md +82 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: codebase-investigator
|
|
3
|
+
description: Read many files in isolation and produce a compact evidence summary. Use when broad codebase exploration would bloat the main context.
|
|
4
|
+
model: haiku
|
|
5
|
+
tools:
|
|
6
|
+
- Read
|
|
7
|
+
- Grep
|
|
8
|
+
- Glob
|
|
9
|
+
- Bash
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Codebase Investigator
|
|
13
|
+
|
|
14
|
+
You are an isolated investigation agent. Your job is to read files, search code, and produce a **compact structured summary** — never dump raw file contents back.
|
|
15
|
+
|
|
16
|
+
## Input
|
|
17
|
+
|
|
18
|
+
You will receive:
|
|
19
|
+
- A **question** or **area to investigate**
|
|
20
|
+
- An optional **list of candidate files**
|
|
21
|
+
- An optional **workspace root**
|
|
22
|
+
|
|
23
|
+
## Process
|
|
24
|
+
|
|
25
|
+
1. **Search** for relevant files using Grep and Glob
|
|
26
|
+
2. **Read** the most relevant files (prioritize by relevance)
|
|
27
|
+
3. **Extract** key information: types, functions, relationships, patterns
|
|
28
|
+
4. **Summarize** findings into a structured report
|
|
29
|
+
|
|
30
|
+
## Output Format
|
|
31
|
+
|
|
32
|
+
Always return this structure:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
## Investigation: [topic]
|
|
36
|
+
|
|
37
|
+
### Key Findings
|
|
38
|
+
- [finding 1]
|
|
39
|
+
- [finding 2]
|
|
40
|
+
- [finding 3]
|
|
41
|
+
|
|
42
|
+
### Relevant Files
|
|
43
|
+
| File | Purpose | Key Symbols |
|
|
44
|
+
|------|---------|-------------|
|
|
45
|
+
| path/to/file.rs | description | symbol1, symbol2 |
|
|
46
|
+
|
|
47
|
+
### Data Flow
|
|
48
|
+
[brief description of how data moves through the area]
|
|
49
|
+
|
|
50
|
+
### Concerns
|
|
51
|
+
- [any issues, risks, or complexity hotspots found]
|
|
52
|
+
|
|
53
|
+
### Confidence: [high/medium/low]
|
|
54
|
+
[why this confidence level]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Rules
|
|
58
|
+
|
|
59
|
+
- **Never return raw file contents** — always summarize
|
|
60
|
+
- Read at most 20 files per investigation
|
|
61
|
+
- If you need more than 20 files, report what you found and suggest follow-up areas
|
|
62
|
+
- Focus on answering the specific question, not exhaustive documentation
|
|
63
|
+
- Flag anything suspicious (dead code, inconsistencies, missing error handling)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: patch-verifier
|
|
3
|
+
description: Review a proposed change for consistency, correctness, and completeness. Use after code changes to verify quality before completion.
|
|
4
|
+
model: sonnet
|
|
5
|
+
tools:
|
|
6
|
+
- Read
|
|
7
|
+
- Grep
|
|
8
|
+
- Glob
|
|
9
|
+
- Bash
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Patch Verifier
|
|
13
|
+
|
|
14
|
+
You are a verification agent. Your job is to review a proposed code change and assess its correctness, consistency, and completeness.
|
|
15
|
+
|
|
16
|
+
## Input
|
|
17
|
+
|
|
18
|
+
You will receive:
|
|
19
|
+
- A **description of the change** (what was done and why)
|
|
20
|
+
- A **list of modified files** or a diff
|
|
21
|
+
- The **original task/plan** that motivated the change
|
|
22
|
+
|
|
23
|
+
## Process
|
|
24
|
+
|
|
25
|
+
1. **Read the diff** or changed files
|
|
26
|
+
2. **Check consistency**: Does the change match the stated intent?
|
|
27
|
+
3. **Check completeness**: Are all necessary changes present? (imports, tests, docs, types)
|
|
28
|
+
4. **Check correctness**: Are there logical errors, edge cases, or regressions?
|
|
29
|
+
5. **Check conventions**: Does the code follow project conventions?
|
|
30
|
+
|
|
31
|
+
## Output Format
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
## Patch Verification Report
|
|
35
|
+
|
|
36
|
+
### Summary
|
|
37
|
+
[1-2 sentence assessment]
|
|
38
|
+
|
|
39
|
+
### Verdict: PASS | FAIL | NEEDS_WORK
|
|
40
|
+
|
|
41
|
+
### Checklist
|
|
42
|
+
- [ ] Change matches stated intent
|
|
43
|
+
- [ ] All affected files updated
|
|
44
|
+
- [ ] No missing imports or type errors
|
|
45
|
+
- [ ] Error handling present where needed
|
|
46
|
+
- [ ] No obvious regressions
|
|
47
|
+
- [ ] Tests updated/added if applicable
|
|
48
|
+
- [ ] No secrets or sensitive data exposed
|
|
49
|
+
|
|
50
|
+
### Issues Found
|
|
51
|
+
| Severity | File | Line | Description |
|
|
52
|
+
|----------|------|------|-------------|
|
|
53
|
+
| high/medium/low | path | N | description |
|
|
54
|
+
|
|
55
|
+
### Missing Items
|
|
56
|
+
- [anything that should have been done but wasn't]
|
|
57
|
+
|
|
58
|
+
### Suggestions
|
|
59
|
+
- [optional improvements, not blockers]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Rules
|
|
63
|
+
|
|
64
|
+
- Be thorough but concise
|
|
65
|
+
- Distinguish between blockers (FAIL) and suggestions (PASS with notes)
|
|
66
|
+
- Never approve a change that introduces obvious bugs or security issues
|
|
67
|
+
- Flag missing test coverage explicitly
|
|
68
|
+
- Do not rewrite the code — only identify issues
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: refactor-reviewer
|
|
3
|
+
description: Inspect refactor scope, hidden impact, and risky omissions. Use during or after refactoring to catch missed references and breaking changes.
|
|
4
|
+
model: sonnet
|
|
5
|
+
tools:
|
|
6
|
+
- Read
|
|
7
|
+
- Grep
|
|
8
|
+
- Glob
|
|
9
|
+
- Bash
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Refactor Reviewer
|
|
13
|
+
|
|
14
|
+
You are a refactoring review agent. Your job is to verify that a refactoring operation is complete and safe — catching missed references, broken imports, and hidden impact.
|
|
15
|
+
|
|
16
|
+
## Input
|
|
17
|
+
|
|
18
|
+
You will receive:
|
|
19
|
+
- **What was refactored** (rename, move, extract, restructure)
|
|
20
|
+
- **List of changed files**
|
|
21
|
+
- **The old and new names/paths/structure**
|
|
22
|
+
|
|
23
|
+
## Process
|
|
24
|
+
|
|
25
|
+
1. **Search for stale references** to the old name/path/structure:
|
|
26
|
+
- Grep for old symbol names, old import paths, old file references
|
|
27
|
+
- Check string literals, comments, documentation, config files
|
|
28
|
+
- Check test files for old references
|
|
29
|
+
|
|
30
|
+
2. **Verify new references are correct**:
|
|
31
|
+
- All imports resolve
|
|
32
|
+
- All type references are valid
|
|
33
|
+
- Re-exports are updated if applicable
|
|
34
|
+
|
|
35
|
+
3. **Check for hidden consumers**:
|
|
36
|
+
- External APIs or CLI commands that reference the old structure
|
|
37
|
+
- Dynamic references (string-based imports, reflection)
|
|
38
|
+
- Build scripts, CI configs, documentation
|
|
39
|
+
|
|
40
|
+
4. **Assess breaking change risk**:
|
|
41
|
+
- Is this a public API change?
|
|
42
|
+
- Are there downstream consumers?
|
|
43
|
+
- Is there a migration path?
|
|
44
|
+
|
|
45
|
+
## Output Format
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
## Refactor Review Report
|
|
49
|
+
|
|
50
|
+
### Summary
|
|
51
|
+
[1-2 sentence assessment]
|
|
52
|
+
|
|
53
|
+
### Verdict: CLEAN | STALE_REFS | BREAKING
|
|
54
|
+
|
|
55
|
+
### Stale References Found
|
|
56
|
+
| File | Line | Reference | Status |
|
|
57
|
+
|------|------|-----------|--------|
|
|
58
|
+
| path | N | old_name | needs update / false positive |
|
|
59
|
+
|
|
60
|
+
### Breaking Changes
|
|
61
|
+
- [list of breaking changes, if any]
|
|
62
|
+
|
|
63
|
+
### Hidden Impact
|
|
64
|
+
- [any indirect effects discovered]
|
|
65
|
+
|
|
66
|
+
### Verification Commands
|
|
67
|
+
```bash
|
|
68
|
+
# Commands to verify the refactor is complete
|
|
69
|
+
[specific build/test/lint commands]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Confidence: [high/medium/low]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Rules
|
|
76
|
+
|
|
77
|
+
- Always search for the **old** name/path in the entire codebase
|
|
78
|
+
- Check at least: source code, tests, docs, configs, CI files
|
|
79
|
+
- Distinguish between actual stale references and false positives (e.g., in comments describing history)
|
|
80
|
+
- Never approve a refactor without verifying no stale imports remain
|
|
81
|
+
- If the scope is very large (>20 files), focus on the highest-risk areas first
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// OCO Hooks — Shared utilities (cross-platform: Windows, Linux, macOS)
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { execSync, execFileSync } from 'node:child_process';
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readSync, writeFileSync, appendFileSync, lstatSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { homedir, tmpdir } from 'node:os';
|
|
7
|
+
|
|
8
|
+
/** Read JSON from stdin (Claude Code pipes hook input). */
|
|
9
|
+
export function readStdin() {
|
|
10
|
+
try {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
const fd = 0; // stdin
|
|
13
|
+
const buf = Buffer.alloc(65536);
|
|
14
|
+
let n;
|
|
15
|
+
try { while ((n = readSync(fd, buf)) > 0) chunks.push(buf.slice(0, n)); } catch {}
|
|
16
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
17
|
+
} catch { return {}; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Async version using process.stdin */
|
|
21
|
+
export async function readStdinAsync() {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
let data = '';
|
|
24
|
+
let resolved = false;
|
|
25
|
+
const done = () => {
|
|
26
|
+
if (resolved) return;
|
|
27
|
+
resolved = true;
|
|
28
|
+
try { resolve(JSON.parse(data)); } catch { resolve({}); }
|
|
29
|
+
};
|
|
30
|
+
process.stdin.setEncoding('utf8');
|
|
31
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
32
|
+
process.stdin.on('end', done);
|
|
33
|
+
process.stdin.on('error', done);
|
|
34
|
+
// Timeout after 3s in case stdin never closes
|
|
35
|
+
setTimeout(done, 3000);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Get a stable session state directory, cross-platform. */
|
|
40
|
+
export function getStateDir() {
|
|
41
|
+
let workspaceRoot;
|
|
42
|
+
try {
|
|
43
|
+
workspaceRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
44
|
+
} catch {
|
|
45
|
+
workspaceRoot = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hash = createHash('md5').update(workspaceRoot).digest('hex').slice(0, 12);
|
|
49
|
+
const cacheRoot = process.env.XDG_RUNTIME_DIR || join(homedir(), '.cache', 'oco');
|
|
50
|
+
|
|
51
|
+
const stateDir = join(cacheRoot, `session-${hash}`);
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(stateDir, { recursive: true });
|
|
54
|
+
// Basic symlink guard
|
|
55
|
+
if (lstatSync(stateDir).isSymbolicLink()) return join(tmpdir(), 'oco-fallback');
|
|
56
|
+
} catch {}
|
|
57
|
+
|
|
58
|
+
return stateDir;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Check if a command exists on PATH. */
|
|
62
|
+
export function commandExists(cmd) {
|
|
63
|
+
try {
|
|
64
|
+
const check = process.platform === 'win32' ? `where ${cmd}` : `command -v ${cmd}`;
|
|
65
|
+
execSync(check, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
66
|
+
return true;
|
|
67
|
+
} catch { return false; }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Run OCO CLI command and return parsed JSON. Degrades gracefully. */
|
|
71
|
+
export function ocoRun(args) {
|
|
72
|
+
const bin = process.env.OCO_BIN || 'oco';
|
|
73
|
+
if (!commandExists(bin)) return null;
|
|
74
|
+
try {
|
|
75
|
+
const result = execFileSync(bin, args, {
|
|
76
|
+
encoding: 'utf8',
|
|
77
|
+
timeout: 5000,
|
|
78
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
return JSON.parse(result);
|
|
81
|
+
} catch { return null; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Read a state file (returns content or default). */
|
|
85
|
+
export function readState(stateDir, filename, defaultVal = '') {
|
|
86
|
+
const p = join(stateDir, filename);
|
|
87
|
+
try { return readFileSync(p, 'utf8').trim(); } catch { return defaultVal; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Write a state file. */
|
|
91
|
+
export function writeState(stateDir, filename, content) {
|
|
92
|
+
try { writeFileSync(join(stateDir, filename), String(content)); } catch {}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Append to a state file. */
|
|
96
|
+
export function appendState(stateDir, filename, content) {
|
|
97
|
+
try { appendFileSync(join(stateDir, filename), content + '\n'); } catch {}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Output structured hook response as JSON. */
|
|
101
|
+
export function respond(obj) {
|
|
102
|
+
process.stdout.write(JSON.stringify(obj));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Write error to stderr (shown to Claude when exit 2). */
|
|
106
|
+
export function blockWith(message) {
|
|
107
|
+
process.stderr.write(message);
|
|
108
|
+
process.exit(2);
|
|
109
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// OCO Hook: PostToolUse (cross-platform)
|
|
2
|
+
// Records observations, tracks modifications and verification timestamps.
|
|
3
|
+
// MUST exit within 4s no matter what.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, appendFileSync, mkdirSync, lstatSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { homedir, tmpdir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
const killTimer = setTimeout(() => process.exit(0), 4000);
|
|
11
|
+
killTimer.unref();
|
|
12
|
+
process.on('uncaughtException', () => process.exit(0));
|
|
13
|
+
process.on('unhandledRejection', () => process.exit(0));
|
|
14
|
+
|
|
15
|
+
// --- Helpers (inlined) ---
|
|
16
|
+
function getStateDir() {
|
|
17
|
+
let root;
|
|
18
|
+
try { root = require('node:child_process').execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim(); } catch { root = process.env.CLAUDE_PROJECT_DIR || process.cwd(); }
|
|
19
|
+
const hash = createHash('md5').update(root).digest('hex').slice(0, 12);
|
|
20
|
+
const dir = join(process.env.XDG_RUNTIME_DIR || join(homedir(), '.cache', 'oco'), `session-${hash}`);
|
|
21
|
+
try { mkdirSync(dir, { recursive: true }); if (lstatSync(dir).isSymbolicLink()) return join(tmpdir(), 'oco-fallback'); } catch {}
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
function readState(dir, file, def = '') { try { return readFileSync(join(dir, file), 'utf8').trim(); } catch { return def; } }
|
|
25
|
+
function writeState(dir, file, val) { try { writeFileSync(join(dir, file), String(val)); } catch {} }
|
|
26
|
+
function appendState(dir, file, val) { try { appendFileSync(join(dir, file), val + '\n'); } catch {} }
|
|
27
|
+
|
|
28
|
+
function readStdin() {
|
|
29
|
+
return new Promise((resolve) => {
|
|
30
|
+
let data = '', done = false;
|
|
31
|
+
const finish = () => { if (done) return; done = true; try { resolve(JSON.parse(data)); } catch { resolve(null); } };
|
|
32
|
+
process.stdin.setEncoding('utf8');
|
|
33
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
34
|
+
process.stdin.on('end', finish);
|
|
35
|
+
process.stdin.on('error', finish);
|
|
36
|
+
setTimeout(finish, 1000);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const input = await readStdin();
|
|
42
|
+
const toolName = input?.tool_name || '';
|
|
43
|
+
const toolError = input?.error || '';
|
|
44
|
+
if (!toolName) process.exit(0);
|
|
45
|
+
|
|
46
|
+
const stateDir = getStateDir();
|
|
47
|
+
const now = Math.floor(Date.now() / 1000);
|
|
48
|
+
|
|
49
|
+
// --- Telemetry: record observation (async, fire-and-forget) ---
|
|
50
|
+
try {
|
|
51
|
+
const bin = process.env.OCO_BIN || 'oco';
|
|
52
|
+
execFile(bin, ['observe', '--tool', toolName, '--status', toolError ? 'error' : 'ok', '--format', 'json'], { timeout: 3000, windowsHide: true });
|
|
53
|
+
} catch {}
|
|
54
|
+
|
|
55
|
+
// --- Track modified files ---
|
|
56
|
+
if (['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
|
|
57
|
+
const filePath = input.tool_input?.file_path || input.tool_input?.path || input.tool_input?.destination || '';
|
|
58
|
+
if (filePath) {
|
|
59
|
+
appendState(stateDir, 'modified-files', filePath);
|
|
60
|
+
writeState(stateDir, 'last-modified-ts', String(now));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// --- Detect verification commands ---
|
|
65
|
+
if (!toolError && (toolName === 'Bash' || toolName === 'bash')) {
|
|
66
|
+
const command = (input.tool_input?.command || '').toLowerCase();
|
|
67
|
+
const verifyCmds = [
|
|
68
|
+
'cargo test', 'cargo build', 'cargo check', 'cargo clippy',
|
|
69
|
+
'npm test', 'npm run build', 'npm run lint',
|
|
70
|
+
'pytest', 'python -m pytest', 'go test', 'go build',
|
|
71
|
+
'tsc --noemit', 'npx tsc', 'mypy', 'ruff check',
|
|
72
|
+
];
|
|
73
|
+
for (const vc of verifyCmds) {
|
|
74
|
+
if (command.startsWith(vc) || command.includes(` && ${vc}`) || command.includes(`; ${vc}`)) {
|
|
75
|
+
writeState(stateDir, 'verify-done', String(now));
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Reset loop counter on success ---
|
|
82
|
+
if (!toolError) {
|
|
83
|
+
const loopFile = `loop-${toolName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
84
|
+
writeState(stateDir, loopFile, '0');
|
|
85
|
+
}
|
|
86
|
+
} catch {}
|
|
87
|
+
|
|
88
|
+
process.exit(0);
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// OCO Hook: PreToolUse (cross-platform)
|
|
2
|
+
// Enforces tool policy gates before execution.
|
|
3
|
+
// MUST exit within 4s no matter what.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, lstatSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { homedir, tmpdir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
const killTimer = setTimeout(() => process.exit(0), 4000);
|
|
11
|
+
killTimer.unref();
|
|
12
|
+
process.on('uncaughtException', () => process.exit(0));
|
|
13
|
+
process.on('unhandledRejection', () => process.exit(0));
|
|
14
|
+
|
|
15
|
+
// --- Helpers (inlined, no external deps) ---
|
|
16
|
+
function getStateDir() {
|
|
17
|
+
let root;
|
|
18
|
+
try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim(); } catch { root = process.env.CLAUDE_PROJECT_DIR || process.cwd(); }
|
|
19
|
+
const hash = createHash('md5').update(root).digest('hex').slice(0, 12);
|
|
20
|
+
const dir = join(process.env.XDG_RUNTIME_DIR || join(homedir(), '.cache', 'oco'), `session-${hash}`);
|
|
21
|
+
try { mkdirSync(dir, { recursive: true }); if (lstatSync(dir).isSymbolicLink()) return join(tmpdir(), 'oco-fallback'); } catch {}
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
function readState(dir, file, def = '') { try { return readFileSync(join(dir, file), 'utf8').trim(); } catch { return def; } }
|
|
25
|
+
function writeState(dir, file, val) { try { writeFileSync(join(dir, file), String(val)); } catch {} }
|
|
26
|
+
function respond(obj) { process.stdout.write(JSON.stringify(obj)); }
|
|
27
|
+
function blockWith(msg) { process.stderr.write(msg); process.exit(2); }
|
|
28
|
+
|
|
29
|
+
function readStdin() {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
let data = '', done = false;
|
|
32
|
+
const finish = () => { if (done) return; done = true; try { resolve(JSON.parse(data)); } catch { resolve(null); } };
|
|
33
|
+
process.stdin.setEncoding('utf8');
|
|
34
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
35
|
+
process.stdin.on('end', finish);
|
|
36
|
+
process.stdin.on('error', finish);
|
|
37
|
+
setTimeout(finish, 1000);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const input = await readStdin();
|
|
43
|
+
const toolName = input?.tool_name || '';
|
|
44
|
+
const toolInput = input?.tool_input || {};
|
|
45
|
+
if (!toolName) process.exit(0);
|
|
46
|
+
|
|
47
|
+
const stateDir = getStateDir();
|
|
48
|
+
|
|
49
|
+
// --- Destructive command detection ---
|
|
50
|
+
if (toolName === 'Bash' || toolName === 'bash') {
|
|
51
|
+
const command = (toolInput.command || '').toLowerCase();
|
|
52
|
+
const destructive = [
|
|
53
|
+
'rm -rf', 'rm -r ', 'rmdir',
|
|
54
|
+
'git reset --hard', 'git push --force', 'git push -f ',
|
|
55
|
+
'git clean -fd', 'git checkout -- .', 'git restore .',
|
|
56
|
+
'drop table', 'drop database', 'truncate table',
|
|
57
|
+
];
|
|
58
|
+
for (const pattern of destructive) {
|
|
59
|
+
if (command.includes(pattern)) {
|
|
60
|
+
blockWith(`OCO policy: destructive command detected (${pattern}). Use a safer alternative or confirm explicitly.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- Sensitive file protection ---
|
|
66
|
+
if (['Edit', 'Write', 'MultiEdit'].includes(toolName)) {
|
|
67
|
+
const filePath = (toolInput.file_path || toolInput.path || '').toLowerCase();
|
|
68
|
+
const sensitive = ['.env', 'credentials', 'secrets', '.key', '.pem', 'id_rsa'];
|
|
69
|
+
for (const pattern of sensitive) {
|
|
70
|
+
if (filePath.includes(pattern)) {
|
|
71
|
+
blockWith(`OCO policy: write to sensitive file (${pattern}) blocked. Review manually.`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Loop detection ---
|
|
77
|
+
const loopFile = `loop-${toolName.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
|
78
|
+
let count = parseInt(readState(stateDir, loopFile, '0'), 10) + 1;
|
|
79
|
+
writeState(stateDir, loopFile, String(count));
|
|
80
|
+
|
|
81
|
+
if (count >= 5) {
|
|
82
|
+
if (count >= 8) writeState(stateDir, loopFile, '0');
|
|
83
|
+
respond({ hookSpecificOutput: { additionalContext: `OCO: tool '${toolName}' called ${count} times. Possible loop — consider a different approach.` } });
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- OCO advanced gate check (optional, fire-and-forget on failure) ---
|
|
88
|
+
try {
|
|
89
|
+
const bin = process.env.OCO_BIN || 'oco';
|
|
90
|
+
const raw = execFileSync(bin, ['gate-check', '--tool', toolName, '--input', JSON.stringify(toolInput), '--format', 'json'], {
|
|
91
|
+
encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true,
|
|
92
|
+
});
|
|
93
|
+
const gateResult = JSON.parse(raw);
|
|
94
|
+
if (gateResult?.decision === 'deny') {
|
|
95
|
+
blockWith(`OCO policy: ${gateResult.reason || 'denied by policy'}`);
|
|
96
|
+
}
|
|
97
|
+
} catch {}
|
|
98
|
+
} catch {}
|
|
99
|
+
|
|
100
|
+
process.exit(0);
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// OCO Hook: Stop (cross-platform)
|
|
2
|
+
// Prevents premature completion when code was modified without verification.
|
|
3
|
+
// MUST exit within 4s no matter what.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, lstatSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { homedir, tmpdir } from 'node:os';
|
|
9
|
+
|
|
10
|
+
const killTimer = setTimeout(() => process.exit(0), 4000);
|
|
11
|
+
killTimer.unref();
|
|
12
|
+
process.on('uncaughtException', () => process.exit(0));
|
|
13
|
+
process.on('unhandledRejection', () => process.exit(0));
|
|
14
|
+
|
|
15
|
+
// --- Helpers (inlined) ---
|
|
16
|
+
function getStateDir() {
|
|
17
|
+
let root;
|
|
18
|
+
try { root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], windowsHide: true }).trim(); } catch { root = process.env.CLAUDE_PROJECT_DIR || process.cwd(); }
|
|
19
|
+
const hash = createHash('md5').update(root).digest('hex').slice(0, 12);
|
|
20
|
+
const dir = join(process.env.XDG_RUNTIME_DIR || join(homedir(), '.cache', 'oco'), `session-${hash}`);
|
|
21
|
+
try { mkdirSync(dir, { recursive: true }); if (lstatSync(dir).isSymbolicLink()) return join(tmpdir(), 'oco-fallback'); } catch {}
|
|
22
|
+
return dir;
|
|
23
|
+
}
|
|
24
|
+
function readState(dir, file, def = '') { try { return readFileSync(join(dir, file), 'utf8').trim(); } catch { return def; } }
|
|
25
|
+
|
|
26
|
+
function readStdin() {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let data = '', done = false;
|
|
29
|
+
const finish = () => { if (done) return; done = true; try { resolve(JSON.parse(data)); } catch { resolve(null); } };
|
|
30
|
+
process.stdin.setEncoding('utf8');
|
|
31
|
+
process.stdin.on('data', (c) => { data += c; });
|
|
32
|
+
process.stdin.on('end', finish);
|
|
33
|
+
process.stdin.on('error', finish);
|
|
34
|
+
setTimeout(finish, 1000);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const input = await readStdin();
|
|
40
|
+
const stopReason = input?.reason || 'complete';
|
|
41
|
+
|
|
42
|
+
// Only enforce verification for completion stops
|
|
43
|
+
if (stopReason !== 'complete' && stopReason !== '') process.exit(0);
|
|
44
|
+
|
|
45
|
+
const stateDir = getStateDir();
|
|
46
|
+
|
|
47
|
+
// Check if files were modified during this session
|
|
48
|
+
const modifiedLog = join(stateDir, 'modified-files');
|
|
49
|
+
if (!existsSync(modifiedLog)) process.exit(0);
|
|
50
|
+
|
|
51
|
+
let modifiedFiles;
|
|
52
|
+
try {
|
|
53
|
+
modifiedFiles = [...new Set(readFileSync(modifiedLog, 'utf8').split('\n').filter(Boolean))];
|
|
54
|
+
} catch { process.exit(0); }
|
|
55
|
+
|
|
56
|
+
if (modifiedFiles.length === 0) process.exit(0);
|
|
57
|
+
|
|
58
|
+
// Ignore non-source files (hooks, configs, docs) — no verification needed
|
|
59
|
+
// Paths may be absolute; match against full path patterns
|
|
60
|
+
const nonSourcePatterns = [/[/\\]\.claude[/\\]/, /[/\\]\.github[/\\]/, /[/\\]docs[/\\]/, /\.md$/i, /\.json$/i, /\.ya?ml$/i, /\.toml$/i, /\.mjs$/i, /\.cjs$/i];
|
|
61
|
+
const sourceFiles = modifiedFiles.filter(f => !nonSourcePatterns.some(p => p.test(f)));
|
|
62
|
+
if (sourceFiles.length === 0) process.exit(0);
|
|
63
|
+
|
|
64
|
+
// Check verification timestamp vs last modification
|
|
65
|
+
const verifyTs = parseInt(readState(stateDir, 'verify-done', '0'), 10);
|
|
66
|
+
const modifiedTs = parseInt(readState(stateDir, 'last-modified-ts', '0'), 10);
|
|
67
|
+
|
|
68
|
+
if (verifyTs >= modifiedTs && verifyTs > 0) {
|
|
69
|
+
// Verification happened after last modification — clean and allow
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(modifiedLog);
|
|
72
|
+
unlinkSync(join(stateDir, 'verify-done'));
|
|
73
|
+
unlinkSync(join(stateDir, 'last-modified-ts'));
|
|
74
|
+
} catch {}
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Determine needed checks from project manifests
|
|
79
|
+
const cwd = input.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
80
|
+
const checks = [];
|
|
81
|
+
if (existsSync(join(cwd, 'Cargo.toml'))) checks.push('build', 'test', 'clippy');
|
|
82
|
+
if (existsSync(join(cwd, 'package.json'))) {
|
|
83
|
+
try {
|
|
84
|
+
const pkg = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8'));
|
|
85
|
+
if (pkg.scripts?.test) checks.push('test');
|
|
86
|
+
if (pkg.scripts?.lint) checks.push('lint');
|
|
87
|
+
} catch { checks.push('test'); }
|
|
88
|
+
}
|
|
89
|
+
if (existsSync(join(cwd, 'pyproject.toml')) || existsSync(join(cwd, 'setup.py'))) checks.push('test', 'typecheck');
|
|
90
|
+
|
|
91
|
+
if (checks.length === 0) process.exit(0);
|
|
92
|
+
|
|
93
|
+
const fileList = sourceFiles.slice(0, 10).join(',');
|
|
94
|
+
process.stderr.write(`OCO: ${sourceFiles.length} file(s) modified [${fileList}] but no verification run detected. Recommended checks: ${checks.join(',')}. Run build/test/lint before completing.`);
|
|
95
|
+
process.exit(2);
|
|
96
|
+
} catch {}
|
|
97
|
+
|
|
98
|
+
process.exit(0);
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// OCO Hook: UserPromptSubmit (CommonJS, callback-based, no deadlock)
|
|
2
|
+
// Non-blocking stdin — avoids readSync deadlock on Windows.
|
|
3
|
+
// ALWAYS writes JSON to stdout — empty stdout triggers false "hook error" in Claude Code.
|
|
4
|
+
// See: https://github.com/anthropics/claude-code/issues/36948
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const EMPTY = JSON.stringify({});
|
|
8
|
+
|
|
9
|
+
const killTimer = setTimeout(() => { process.stdout.write(EMPTY); process.exit(0); }, 3000);
|
|
10
|
+
killTimer.unref();
|
|
11
|
+
process.on('uncaughtException', () => { process.stdout.write(EMPTY); process.exit(0); });
|
|
12
|
+
process.on('unhandledRejection', () => { process.stdout.write(EMPTY); process.exit(0); });
|
|
13
|
+
|
|
14
|
+
let data = '';
|
|
15
|
+
let handled = false;
|
|
16
|
+
|
|
17
|
+
function done(json) {
|
|
18
|
+
if (handled) return;
|
|
19
|
+
handled = true;
|
|
20
|
+
process.stdout.write(json || EMPTY);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function handle() {
|
|
25
|
+
try {
|
|
26
|
+
const input = data ? JSON.parse(data) : null;
|
|
27
|
+
if (!input || !input.prompt) return done();
|
|
28
|
+
|
|
29
|
+
const prompt = input.prompt.toLowerCase();
|
|
30
|
+
|
|
31
|
+
const highPatterns = /\b(refactor|rewrite|migrate|redesign|overhaul|rearchitect|restructure)\b/;
|
|
32
|
+
const criticalPatterns = /\b(delete.*prod|drop.*database|reset.*hard|force.*push|rm\s+-rf)\b/;
|
|
33
|
+
const verifyPatterns = /\b(fix|bug|patch|repair|resolve|debug|test|build|deploy)\b/;
|
|
34
|
+
|
|
35
|
+
let complexity = 'medium';
|
|
36
|
+
if (criticalPatterns.test(prompt)) complexity = 'critical';
|
|
37
|
+
else if (highPatterns.test(prompt)) complexity = 'high';
|
|
38
|
+
else if (prompt.length < 30 && !verifyPatterns.test(prompt)) complexity = 'trivial';
|
|
39
|
+
|
|
40
|
+
if (complexity === 'trivial') return done();
|
|
41
|
+
|
|
42
|
+
const needsVerify = verifyPatterns.test(prompt) || complexity === 'high' || complexity === 'critical';
|
|
43
|
+
|
|
44
|
+
let guidance = '[OCO] complexity=' + complexity + ' verify=' + needsVerify;
|
|
45
|
+
if (complexity === 'high' || complexity === 'critical') {
|
|
46
|
+
guidance += ' | Recommended: investigate before acting.';
|
|
47
|
+
} else if (needsVerify) {
|
|
48
|
+
guidance += ' | Verify after changes.';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
done(JSON.stringify({ hookSpecificOutput: { additionalContext: guidance } }));
|
|
52
|
+
} catch (e) {
|
|
53
|
+
done();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
process.stdin.setEncoding('utf8');
|
|
58
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
59
|
+
process.stdin.on('end', handle);
|
|
60
|
+
process.stdin.on('error', () => done());
|
|
61
|
+
setTimeout(handle, 500);
|