ai-guard-plugins 1.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/.github/workflows/publish.yml +42 -0
- package/.github/workflows/release.yml +156 -0
- package/CONTRIBUTING.md +56 -0
- package/LICENSE +21 -0
- package/README.md +247 -0
- package/install.sh +129 -0
- package/install.yaml +49 -0
- package/llms.txt +33 -0
- package/package.json +24 -0
- package/plugins/auto-lint-fix.ts +39 -0
- package/plugins/auto-summarize-input.ts +45 -0
- package/plugins/billing-guard.ts +101 -0
- package/plugins/capture-insights.ts +215 -0
- package/plugins/cline/hooks/PostToolUse +73 -0
- package/plugins/cline/hooks/PreToolUse +200 -0
- package/plugins/cline/hooks/TaskComplete +134 -0
- package/plugins/cline/hooks/UserPromptSubmit +29 -0
- package/plugins/cursor/rules/auto-lint-fix.mdc +21 -0
- package/plugins/cursor/rules/auto-summarize-input.mdc +17 -0
- package/plugins/cursor/rules/billing-guard.mdc +26 -0
- package/plugins/cursor/rules/capture-insights.mdc +32 -0
- package/plugins/cursor/rules/code-standards.mdc +77 -0
- package/plugins/cursor/rules/dry-guard.mdc +31 -0
- package/plugins/cursorrules-enforcer.ts +124 -0
- package/plugins/dry-guard.ts +107 -0
- package/plugins/kilo/rules/auto-lint-fix.md +15 -0
- package/plugins/kilo/rules/auto-summarize-input.md +12 -0
- package/plugins/kilo/rules/billing-guard.md +21 -0
- package/plugins/kilo/rules/capture-insights.md +27 -0
- package/plugins/kilo/rules/code-standards.md +71 -0
- package/plugins/kilo/rules/dry-guard.md +25 -0
package/install.yaml
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
name: AI Guard Plugins Installer
|
|
2
|
+
description: Install production-hardened plugins for Claude Code & OpenCode
|
|
3
|
+
|
|
4
|
+
steps:
|
|
5
|
+
- id: platform
|
|
6
|
+
type: select
|
|
7
|
+
message: Which AI coding assistant are you using?
|
|
8
|
+
choices:
|
|
9
|
+
- label: OpenCode
|
|
10
|
+
value: opencode
|
|
11
|
+
- label: Claude Code
|
|
12
|
+
value: claude
|
|
13
|
+
- label: Cline
|
|
14
|
+
value: cline
|
|
15
|
+
- label: Cursor
|
|
16
|
+
value: cursor
|
|
17
|
+
- label: Kilo Code
|
|
18
|
+
value: kilo
|
|
19
|
+
|
|
20
|
+
- id: plugins
|
|
21
|
+
type: multiselect
|
|
22
|
+
message: Which plugins do you want to install?
|
|
23
|
+
choices:
|
|
24
|
+
- label: "๐ณ billing-guard โ Catch billing errors before infinite retry freezes"
|
|
25
|
+
value: billing-guard
|
|
26
|
+
checked: true
|
|
27
|
+
- label: "๐งน auto-lint-fix โ Auto-format code after every write/edit"
|
|
28
|
+
value: auto-lint-fix
|
|
29
|
+
checked: true
|
|
30
|
+
- label: "๐ฆ auto-summarize-input โ Compress large inputs to save tokens"
|
|
31
|
+
value: auto-summarize-input
|
|
32
|
+
checked: true
|
|
33
|
+
- label: "๐ง capture-insights โ Learn from recurring mistakes"
|
|
34
|
+
value: capture-insights
|
|
35
|
+
checked: true
|
|
36
|
+
- label: "๐ cursorrules-enforcer โ Enforce coding standards on writes"
|
|
37
|
+
value: cursorrules-enforcer
|
|
38
|
+
checked: true
|
|
39
|
+
- label: "๐ dry-guard โ Warn before duplicating existing code"
|
|
40
|
+
value: dry-guard
|
|
41
|
+
checked: true
|
|
42
|
+
|
|
43
|
+
- id: confirm
|
|
44
|
+
type: confirm
|
|
45
|
+
message: "Install selected plugins?"
|
|
46
|
+
|
|
47
|
+
output:
|
|
48
|
+
platform: "{{ platform }}"
|
|
49
|
+
plugins: "{{ plugins }}"
|
package/llms.txt
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# ai-guard-plugins
|
|
2
|
+
|
|
3
|
+
Production-hardened plugins for Claude Code & OpenCode AI coding assistants.
|
|
4
|
+
|
|
5
|
+
## What this repo contains
|
|
6
|
+
|
|
7
|
+
6 TypeScript plugins that intercept AI agent behavior to prevent costly mistakes:
|
|
8
|
+
|
|
9
|
+
1. billing-guard.ts โ Detects billing/quota errors and interrupts before infinite retry freezes
|
|
10
|
+
2. auto-lint-fix.ts โ Auto-runs Biome formatter after every file write/edit
|
|
11
|
+
3. auto-summarize-input.ts โ Compresses inputs >10K chars via Claude Haiku to save context tokens
|
|
12
|
+
4. capture-insights.ts โ Tracks recurring mistake patterns across sessions in persistent JSONL
|
|
13
|
+
5. cursorrules-enforcer.ts โ Blocks writes containing `as any`, `@ts-ignore`, TODOs, require()
|
|
14
|
+
6. dry-guard.ts โ Warns before creating files that duplicate existing code in the project
|
|
15
|
+
|
|
16
|
+
## Plugin API
|
|
17
|
+
|
|
18
|
+
All plugins implement the `Plugin` type from `@opencode-ai/plugin` and use these hooks:
|
|
19
|
+
- `tool.execute.before` โ intercept before a tool runs (can throw to block)
|
|
20
|
+
- `tool.execute.after` โ react after a tool completes
|
|
21
|
+
- `event` โ listen to session events like `message.updated` and `session.idle`
|
|
22
|
+
- `tui.prompt.append` โ intercept user input before it reaches the model
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
Plugins are standalone .ts files copied to `~/.config/opencode/plugins/` (OpenCode) or `~/.claude/plugins/` (Claude Code).
|
|
27
|
+
|
|
28
|
+
## Key patterns
|
|
29
|
+
|
|
30
|
+
- Plugins throw Error to block operations (cursorrules-enforcer, billing-guard)
|
|
31
|
+
- Plugins use console.warn for advisory warnings (dry-guard)
|
|
32
|
+
- Plugins use persistent storage via JSONL files (capture-insights)
|
|
33
|
+
- Plugins shell out via `$` tagged template for system commands (auto-lint-fix, dry-guard)
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ai-guard-plugins",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "> Production-hardened plugins for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) & [OpenCode](https://opencode.ai) โ battle-tested hooks from real AI-assisted development that catch costly mistakes before they happen.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/YosefHayim/ai-guard-plugins.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [],
|
|
14
|
+
"author": "",
|
|
15
|
+
"license": "ISC",
|
|
16
|
+
"type": "commonjs",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/YosefHayim/ai-guard-plugins/issues"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/YosefHayim/ai-guard-plugins#readme",
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"pr-prism": "^1.1.6"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
|
|
3
|
+
const LINTABLE_EXTS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs', '.cts', '.cjs']);
|
|
4
|
+
const SKIP_PATHS = ['node_modules', 'dist', '.next', 'build', '.turbo'];
|
|
5
|
+
const BIOME = process.env.BIOME_PATH ?? 'biome';
|
|
6
|
+
|
|
7
|
+
function getExt(filePath: string): string {
|
|
8
|
+
const dot = filePath.lastIndexOf('.');
|
|
9
|
+
return dot === -1 ? '' : filePath.slice(dot);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const AutoLintFixPlugin: Plugin = async ({ $ }) => {
|
|
13
|
+
try {
|
|
14
|
+
await $`which ${BIOME}`.quiet();
|
|
15
|
+
} catch {
|
|
16
|
+
console.warn('[auto-lint-fix] biome not found โ plugin disabled');
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
'tool.execute.after': async (input, _output) => {
|
|
22
|
+
if (input.tool !== 'write' && input.tool !== 'edit') return;
|
|
23
|
+
|
|
24
|
+
const args = (_output?.args ?? input.args) as Record<string, unknown> | undefined;
|
|
25
|
+
if (!args) return;
|
|
26
|
+
|
|
27
|
+
const filePath = (args.filePath ?? args.file_path) as string | undefined;
|
|
28
|
+
if (!filePath) return;
|
|
29
|
+
|
|
30
|
+
if (!LINTABLE_EXTS.has(getExt(filePath))) return;
|
|
31
|
+
const normalizedPath = filePath.replace(/\\/g, '/');
|
|
32
|
+
if (SKIP_PATHS.some((p) => normalizedPath.includes(`/${p}/`) || normalizedPath.startsWith(`${p}/`))) return;
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await $`${BIOME} check --fix --unsafe ${filePath}`.quiet().nothrow();
|
|
36
|
+
} catch {}
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
|
|
3
|
+
const THRESHOLD = 10000;
|
|
4
|
+
|
|
5
|
+
export const AutoSummarizeInputPlugin: Plugin = async ({ client }) => {
|
|
6
|
+
return {
|
|
7
|
+
'tui.prompt.append': async (_input, output) => {
|
|
8
|
+
const text = String(output?.text ?? '');
|
|
9
|
+
if (text.length <= THRESHOLD) return;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const charCount = text.length;
|
|
13
|
+
console.log(`๐ฆ Input too large (${charCount} chars). Auto-summarizing via Claude...`);
|
|
14
|
+
|
|
15
|
+
const result = await client.chat.create({
|
|
16
|
+
body: {
|
|
17
|
+
model: 'claude-haiku-4-5-20250415',
|
|
18
|
+
max_tokens: 2000,
|
|
19
|
+
messages: [
|
|
20
|
+
{
|
|
21
|
+
role: 'user',
|
|
22
|
+
content: `Summarize this input for a coding assistant. Keep ALL code snippets, file paths, error messages, and requirements verbatim. Remove ONLY redundancy, repetition, and filler. Output ONLY the summarized input:\n\n${text}`,
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const summary =
|
|
29
|
+
result?.content?.find?.((b: Record<string, unknown>) => b.type === 'text')?.text ??
|
|
30
|
+
result?.content?.[0]?.text ??
|
|
31
|
+
'';
|
|
32
|
+
|
|
33
|
+
if (summary && summary.length < charCount && output?.text === text) {
|
|
34
|
+
const reduction = Math.round(((charCount - summary.length) / charCount) * 100);
|
|
35
|
+
output.text = `[Auto-summarized: ${charCount} chars โ ${summary.length} chars (${reduction}% reduction)]\n\n${summary}`;
|
|
36
|
+
console.log(
|
|
37
|
+
`๐ฆ Auto-summarized: ${charCount} โ ${summary.length} chars (${reduction}% saved)`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.warn(`[auto-summarize] Failed: ${err}`);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
|
|
3
|
+
const BILLING_PATTERNS = [
|
|
4
|
+
/No payment method/i,
|
|
5
|
+
/Add a payment method/i,
|
|
6
|
+
/account is not active.*billing/i,
|
|
7
|
+
/billing details/i,
|
|
8
|
+
/payment.*required/i,
|
|
9
|
+
/quota.*exceeded/i,
|
|
10
|
+
/insufficient.*funds/i,
|
|
11
|
+
/rate limit.*billing/i,
|
|
12
|
+
/plan.*expired/i,
|
|
13
|
+
/subscription.*inactive/i,
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function isBillingError(text: string): string | null {
|
|
17
|
+
for (const pattern of BILLING_PATTERNS) {
|
|
18
|
+
const match = text.match(pattern);
|
|
19
|
+
if (match) return match[0];
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function safeStringify(value: unknown): string {
|
|
25
|
+
try {
|
|
26
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
27
|
+
} catch {
|
|
28
|
+
return String(value);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractText(msg: Record<string, unknown>): string {
|
|
33
|
+
if (typeof msg.content === 'string') return msg.content;
|
|
34
|
+
if (Array.isArray(msg.content)) {
|
|
35
|
+
return (msg.content as Array<Record<string, unknown>>)
|
|
36
|
+
.map((p: Record<string, unknown>) => (typeof p.text === 'string' ? p.text : ''))
|
|
37
|
+
.join(' ');
|
|
38
|
+
}
|
|
39
|
+
if (typeof msg.text === 'string') return msg.text;
|
|
40
|
+
if (typeof msg.output === 'string') return msg.output;
|
|
41
|
+
if (typeof msg.error === 'string') return msg.error;
|
|
42
|
+
if (typeof msg.result === 'string') return msg.result;
|
|
43
|
+
if (Array.isArray(msg.parts)) {
|
|
44
|
+
return (msg.parts as Array<Record<string, unknown>>)
|
|
45
|
+
.map((p: Record<string, unknown>) => (typeof p.text === 'string' ? p.text : ''))
|
|
46
|
+
.join(' ');
|
|
47
|
+
}
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const BillingGuardPlugin: Plugin = async () => {
|
|
52
|
+
return {
|
|
53
|
+
'tool.execute.after': async (_input, output) => {
|
|
54
|
+
const result = output?.result;
|
|
55
|
+
if (!result) return;
|
|
56
|
+
|
|
57
|
+
const text = safeStringify(result);
|
|
58
|
+
const match = isBillingError(text);
|
|
59
|
+
|
|
60
|
+
if (match) {
|
|
61
|
+
console.error(`\n๐ณ BILLING ERROR DETECTED: "${match}"`);
|
|
62
|
+
console.error('โก Interrupting to prevent infinite retry freeze.\n');
|
|
63
|
+
throw new Error(
|
|
64
|
+
`BILLING ERROR: ${match}. The API provider requires payment. Fix billing before retrying. Do NOT retry this operation.`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
'tool.execute.before': async (input, output) => {
|
|
70
|
+
if (input.tool !== 'task') return;
|
|
71
|
+
|
|
72
|
+
const args = (output?.args ?? input.args) as Record<string, unknown> | undefined;
|
|
73
|
+
if (!args) return;
|
|
74
|
+
|
|
75
|
+
const prompt = String(args.prompt ?? '');
|
|
76
|
+
const match = isBillingError(prompt);
|
|
77
|
+
if (match) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`BILLING ERROR in task prompt: ${match}. Do NOT delegate โ billing issue must be resolved first.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
event: async ({ event }) => {
|
|
85
|
+
if (event.type !== 'message.updated') return;
|
|
86
|
+
|
|
87
|
+
const msg = event.properties as Record<string, unknown>;
|
|
88
|
+
const text = extractText(msg);
|
|
89
|
+
if (!text) return;
|
|
90
|
+
|
|
91
|
+
const match = isBillingError(text);
|
|
92
|
+
if (match) {
|
|
93
|
+
console.error(`\n๐ณ BILLING ERROR in message stream: "${match}"`);
|
|
94
|
+
console.error('โก Agent must stop all operations and notify the user.\n');
|
|
95
|
+
throw new Error(
|
|
96
|
+
`BILLING ERROR: ${match}. STOP all operations. Do NOT retry or delegate. Notify the user that billing needs to be fixed.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { Plugin } from '@opencode-ai/plugin';
|
|
2
|
+
|
|
3
|
+
const MEMORY_FILE = `${process.env.HOME ?? require('node:os').homedir()}/.claude/memory/insights.jsonl`;
|
|
4
|
+
|
|
5
|
+
interface InsightEntry {
|
|
6
|
+
id: string;
|
|
7
|
+
insight: string;
|
|
8
|
+
count: number;
|
|
9
|
+
last: string;
|
|
10
|
+
projects: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const PATTERN_CHECKS: Array<{ regex: RegExp; id: string; insight: string }> = [
|
|
14
|
+
{
|
|
15
|
+
regex: /DRY|don't repeat|already exists|duplicat/i,
|
|
16
|
+
id: 'dry-violation',
|
|
17
|
+
insight:
|
|
18
|
+
'Check existing files before creating new ones. Extract shared utils when there is clear reuse.',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
regex: /unused import|unused variable|dead code/i,
|
|
22
|
+
id: 'unused-code',
|
|
23
|
+
insight: 'Biome --fix handles unused imports/variables automatically.',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
regex: /as any|ts-ignore|ts-expect-error/i,
|
|
27
|
+
id: 'type-safety-bypass',
|
|
28
|
+
insight:
|
|
29
|
+
'Never use as any, @ts-ignore, or @ts-expect-error. Use the most specific correct type.',
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
regex: /refactor.*minimal|minimal change|scope creep/i,
|
|
33
|
+
id: 'scope-discipline',
|
|
34
|
+
insight: 'Make the minimal change needed. Do not refactor while fixing.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
regex: /circular dependency|circular import/i,
|
|
38
|
+
id: 'circular-dep',
|
|
39
|
+
insight: 'Restructure module boundaries to break circular dependencies.',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
regex: /re-export|barrel file|index file.*logic/i,
|
|
43
|
+
id: 're-export',
|
|
44
|
+
insight: 'Avoid re-exports. Index files may only re-export modules, never logic.',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
regex: /process\.cwd|project root.*monorepo/i,
|
|
48
|
+
id: 'path-resolution',
|
|
49
|
+
insight:
|
|
50
|
+
'Never assume process.cwd() equals project root in monorepos. Use explicit root resolver.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
regex: /migration|schema change|database migrat/i,
|
|
54
|
+
id: 'data-migration',
|
|
55
|
+
insight: 'Verify schema + data integrity after migrations. Make migrations idempotent.',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
regex: /workaround|hack|bandaid|band-aid/i,
|
|
59
|
+
id: 'workaround',
|
|
60
|
+
insight: 'Document why workaround was needed and plan a proper fix.',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
regex: /performance|slow query|optimize|bottleneck/i,
|
|
64
|
+
id: 'performance',
|
|
65
|
+
insight: "Profile before optimizing. Measure, don't guess.",
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
regex: /security|secret|credential|api.key|token leak/i,
|
|
69
|
+
id: 'security',
|
|
70
|
+
insight: 'Review credentials handling. Never commit secrets.',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
regex: /test.*mock.*implementation|mock.*detail/i,
|
|
74
|
+
id: 'test-mock',
|
|
75
|
+
insight: 'Mock behavior (modules), not implementation details (process.cwd, process.env).',
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
function readEntries(): Map<string, InsightEntry> {
|
|
80
|
+
const map = new Map<string, InsightEntry>();
|
|
81
|
+
try {
|
|
82
|
+
const fs = require('node:fs');
|
|
83
|
+
if (!fs.existsSync(MEMORY_FILE)) return map;
|
|
84
|
+
const lines = fs.readFileSync(MEMORY_FILE, 'utf-8').split('\n').filter(Boolean);
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
try {
|
|
87
|
+
const entry = JSON.parse(line) as InsightEntry;
|
|
88
|
+
map.set(entry.id, entry);
|
|
89
|
+
} catch {}
|
|
90
|
+
}
|
|
91
|
+
} catch {}
|
|
92
|
+
return map;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function writeEntries(entries: Map<string, InsightEntry>): void {
|
|
96
|
+
try {
|
|
97
|
+
const fs = require('node:fs');
|
|
98
|
+
const path = require('node:path');
|
|
99
|
+
const dir = path.dirname(MEMORY_FILE);
|
|
100
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
101
|
+
const content = `${[...entries.values()].map((e) => JSON.stringify(e)).join('\n')}\n`;
|
|
102
|
+
fs.writeFileSync(MEMORY_FILE, content);
|
|
103
|
+
} catch {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const CaptureInsightsPlugin: Plugin = async ({ directory }) => {
|
|
107
|
+
let sessionBuffer: string[] = [];
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
event: async ({ event }) => {
|
|
111
|
+
if (event.type === 'message.updated') {
|
|
112
|
+
const msg = event.properties as Record<string, unknown>;
|
|
113
|
+
const text = extractText(msg);
|
|
114
|
+
if (text) {
|
|
115
|
+
sessionBuffer.push(text);
|
|
116
|
+
if (sessionBuffer.length > 50) sessionBuffer = sessionBuffer.slice(-50);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (event.type === 'session.idle') {
|
|
121
|
+
if (sessionBuffer.length === 0) return;
|
|
122
|
+
|
|
123
|
+
const combined = sessionBuffer.join('\n');
|
|
124
|
+
const matched = PATTERN_CHECKS.filter((p) => p.regex.test(combined));
|
|
125
|
+
|
|
126
|
+
if (matched.length === 0) {
|
|
127
|
+
sessionBuffer = [];
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const entries = readEntries();
|
|
132
|
+
const now = new Date().toISOString().slice(0, 16);
|
|
133
|
+
const projectName = directory.split('/').pop() ?? 'unknown';
|
|
134
|
+
|
|
135
|
+
for (const m of matched) {
|
|
136
|
+
const existing = entries.get(m.id);
|
|
137
|
+
if (existing) {
|
|
138
|
+
existing.count += 1;
|
|
139
|
+
existing.last = now;
|
|
140
|
+
const projects = existing.projects.split(',').filter(Boolean);
|
|
141
|
+
if (!projects.includes(projectName)) {
|
|
142
|
+
existing.projects += `,${projectName}`;
|
|
143
|
+
}
|
|
144
|
+
} else {
|
|
145
|
+
entries.set(m.id, {
|
|
146
|
+
id: m.id,
|
|
147
|
+
insight: m.insight,
|
|
148
|
+
count: 1,
|
|
149
|
+
last: now,
|
|
150
|
+
projects: projectName,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
writeEntries(entries);
|
|
156
|
+
squeezy(entries);
|
|
157
|
+
|
|
158
|
+
const captured = matched.map((m) => {
|
|
159
|
+
const e = entries.get(m.id);
|
|
160
|
+
return ` ๐ ${m.id} (count: ${e?.count ?? 1})`;
|
|
161
|
+
});
|
|
162
|
+
console.log(
|
|
163
|
+
`๐ง Insights captured (${projectName}, ${entries.size} total entries):\n${captured.join('\n')}`,
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
sessionBuffer = [];
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const SQUEEZY_MAX = 15;
|
|
173
|
+
|
|
174
|
+
function squeezy(entries: Map<string, InsightEntry>): void {
|
|
175
|
+
if (entries.size <= SQUEEZY_MAX) return;
|
|
176
|
+
|
|
177
|
+
const sorted = [...entries.values()].sort((a, b) => b.count - a.count);
|
|
178
|
+
const kept = sorted.slice(0, SQUEEZY_MAX);
|
|
179
|
+
const overflow = sorted.slice(SQUEEZY_MAX);
|
|
180
|
+
|
|
181
|
+
for (const extra of overflow) {
|
|
182
|
+
const familyPrefix = extra.id.split('-')[0];
|
|
183
|
+
const target = kept.find((k) => k.id.split('-')[0] === familyPrefix);
|
|
184
|
+
if (target) {
|
|
185
|
+
target.count += extra.count;
|
|
186
|
+
const combinedInsights = `${target.insight} ${extra.insight}`
|
|
187
|
+
.split('. ')
|
|
188
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
189
|
+
.join('. ');
|
|
190
|
+
target.insight = combinedInsights;
|
|
191
|
+
const combinedProjects = `${target.projects},${extra.projects}`
|
|
192
|
+
.split(',')
|
|
193
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
194
|
+
.join(',');
|
|
195
|
+
target.projects = combinedProjects;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const squeezed = new Map<string, InsightEntry>();
|
|
200
|
+
for (const entry of kept) {
|
|
201
|
+
squeezed.set(entry.id, entry);
|
|
202
|
+
}
|
|
203
|
+
writeEntries(squeezed);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function extractText(msg: Record<string, unknown>): string {
|
|
207
|
+
if (typeof msg.content === 'string') return msg.content;
|
|
208
|
+
if (typeof msg.text === 'string') return msg.text;
|
|
209
|
+
if (Array.isArray(msg.parts)) {
|
|
210
|
+
return (msg.parts as Array<Record<string, unknown>>)
|
|
211
|
+
.map((p) => (typeof p.text === 'string' ? p.text : ''))
|
|
212
|
+
.join(' ');
|
|
213
|
+
}
|
|
214
|
+
return '';
|
|
215
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# =============================================================================
|
|
3
|
+
# AI Guard Plugin: PostToolUse Hook for Cline
|
|
4
|
+
# Combines: billing-guard (after), auto-lint-fix
|
|
5
|
+
#
|
|
6
|
+
# Requires: jq (https://jqlang.github.io/jq/)
|
|
7
|
+
# Optional: biome (https://biomejs.dev/) for auto-formatting
|
|
8
|
+
# Contract: reads JSON from stdin, outputs JSON to stdout
|
|
9
|
+
# =============================================================================
|
|
10
|
+
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
INPUT=$(cat)
|
|
14
|
+
|
|
15
|
+
TOOL=$(echo "$INPUT" | jq -r '.postToolUse.tool // empty' 2>/dev/null || true)
|
|
16
|
+
PARAMS=$(echo "$INPUT" | jq -c '.postToolUse.parameters // {}' 2>/dev/null || echo '{}')
|
|
17
|
+
RESULT=$(echo "$INPUT" | jq -r '.postToolUse.result // empty' 2>/dev/null || true)
|
|
18
|
+
|
|
19
|
+
# Default: allow
|
|
20
|
+
allow() { echo '{"cancel":false,"contextModification":"","errorMessage":""}'; exit 0; }
|
|
21
|
+
cancel() { echo "{\"cancel\":true,\"contextModification\":\"\",\"errorMessage\":$(echo "$1" | jq -Rs .)}"; exit 0; }
|
|
22
|
+
inform() { echo "{\"cancel\":false,\"contextModification\":$(echo "$1" | jq -Rs .),\"errorMessage\":\"\"}"; exit 0; }
|
|
23
|
+
|
|
24
|
+
[ -z "$TOOL" ] && allow
|
|
25
|
+
|
|
26
|
+
# =============================================================================
|
|
27
|
+
# 1. BILLING GUARD (after) โ detect billing errors in tool results
|
|
28
|
+
# =============================================================================
|
|
29
|
+
if [ -n "$RESULT" ]; then
|
|
30
|
+
if echo "$RESULT" | grep -qiE \
|
|
31
|
+
'payment.method|payment.required|account.is.not.active.*billing|billing.details|quota.exceeded|insufficient.funds|rate.limit.*billing|plan.expired|subscription.inactive'; then
|
|
32
|
+
cancel "BILLING ERROR detected in tool result. The API provider requires payment. Fix billing before retrying. Do NOT retry this operation."
|
|
33
|
+
fi
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# =============================================================================
|
|
37
|
+
# 2. AUTO LINT FIX โ run biome after file writes
|
|
38
|
+
# =============================================================================
|
|
39
|
+
if [ "$TOOL" = "write_to_file" ] || [ "$TOOL" = "insert_code_block" ] || [ "$TOOL" = "search_and_replace" ]; then
|
|
40
|
+
FILE_PATH=$(echo "$PARAMS" | jq -r '.path // empty' 2>/dev/null || true)
|
|
41
|
+
[ -z "$FILE_PATH" ] && allow
|
|
42
|
+
|
|
43
|
+
# Only lint code files
|
|
44
|
+
EXT="${FILE_PATH##*.}"
|
|
45
|
+
case "$EXT" in
|
|
46
|
+
ts|tsx|js|jsx|mts|mjs|cts|cjs) ;;
|
|
47
|
+
*) allow ;;
|
|
48
|
+
esac
|
|
49
|
+
|
|
50
|
+
# Skip excluded paths (handle both root-level and nested)
|
|
51
|
+
for skip in node_modules dist .next build .turbo; do
|
|
52
|
+
[[ "$FILE_PATH" == "$skip/"* || "$FILE_PATH" == *"/$skip/"* ]] && allow
|
|
53
|
+
done
|
|
54
|
+
|
|
55
|
+
# Find biome binary
|
|
56
|
+
BIOME="${BIOME_PATH:-biome}"
|
|
57
|
+
if ! command -v "$BIOME" &>/dev/null; then
|
|
58
|
+
if [ -f "./node_modules/.bin/biome" ]; then
|
|
59
|
+
BIOME="./node_modules/.bin/biome"
|
|
60
|
+
else
|
|
61
|
+
allow
|
|
62
|
+
fi
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# Run biome fix
|
|
66
|
+
if "$BIOME" check --fix --unsafe "$FILE_PATH" &>/dev/null; then
|
|
67
|
+
inform "[auto-lint-fix] Auto-formatted ${FILE_PATH##*/} with Biome."
|
|
68
|
+
else
|
|
69
|
+
allow
|
|
70
|
+
fi
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
allow
|