opencode-overclock 0.3.0 → 0.5.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/README.md +252 -111
- package/package.json +6 -4
- package/skills/codebase-design/DEEPENING.md +35 -0
- package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
- package/skills/codebase-design/SKILL.md +93 -0
- package/skills/diagnosing-bugs/SKILL.md +123 -0
- package/skills/domain-modeling/ADR-FORMAT.md +55 -0
- package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
- package/skills/domain-modeling/SKILL.md +102 -0
- package/skills/doubt/SKILL.md +80 -0
- package/skills/grilling/SKILL.md +96 -0
- package/skills/source-discipline/SKILL.md +78 -0
- package/skills/tdd/SKILL.md +87 -0
- package/skills/to-spec/SKILL.md +69 -0
- package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
- package/skills/to-tickets/SKILL.md +74 -0
- package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
- package/src/bridge.ts +1 -0
- package/src/buddy/companion.ts +104 -5
- package/src/buddy/sprites.ts +4 -4
- package/src/buddy/tui.ts +175 -65
- package/src/core/bridge.ts +34 -0
- package/src/core/lifecycle.ts +67 -0
- package/src/core/policy.ts +128 -0
- package/src/core/summary.ts +33 -0
- package/src/core/types.ts +193 -0
- package/src/features/buddy.ts +1 -2
- package/src/features/guard.ts +421 -37
- package/src/features/index.ts +18 -4
- package/src/features/recovery.ts +153 -0
- package/src/features/safety.ts +147 -0
- package/src/features/sched.ts +183 -89
- package/src/features/tasks.ts +134 -33
- package/src/features/truncator.ts +116 -0
- package/src/features/usage.ts +46 -65
- package/src/features/workflow.ts +256 -0
- package/src/index.ts +96 -67
- package/src/lib/busy.ts +1 -25
- package/src/lib/exec.ts +13 -0
- package/src/lib/inject.ts +10 -56
- package/src/lib/mirror.ts +13 -0
- package/src/lib/probe.ts +1 -15
- package/src/lib/state.ts +10 -39
- package/src/lib/tmux.ts +1 -0
- package/src/lib/ui.ts +208 -0
- package/src/merge.ts +2 -66
- package/src/platform/probe.ts +25 -0
- package/src/platform/process/exec.ts +317 -0
- package/src/platform/process/tmux.ts +60 -0
- package/src/platform/session/busy.ts +33 -0
- package/src/platform/session/inject.ts +89 -0
- package/src/platform/session/notify.ts +20 -0
- package/src/platform/storage/state.ts +99 -0
- package/src/platform/storage/store.ts +61 -0
- package/src/summary.ts +1 -0
- package/src/tools.ts +8 -244
- package/src/tui.ts +57 -186
- package/src/types.ts +1 -73
- package/src/v2/context.ts +470 -0
- package/src/v2/host.ts +120 -0
- package/src/v2/loader.ts +150 -0
- package/src/workflow/agents/codebase-researcher.ts +27 -0
- package/src/workflow/agents/design-explorer.ts +33 -0
- package/src/workflow/agents/doubt-reviewer.ts +26 -0
- package/src/workflow/agents/engineering-coach.ts +23 -0
- package/src/workflow/agents/performance-auditor.ts +29 -0
- package/src/workflow/agents/security-auditor.ts +23 -0
- package/src/workflow/agents/spec-reviewer.ts +15 -0
- package/src/workflow/agents/standards-reviewer.ts +24 -0
- package/src/workflow/agents/test-engineer.ts +28 -0
- package/src/workflow/catalog.ts +210 -0
- package/src/workflow/templates/build.ts +47 -0
- package/src/workflow/templates/define.ts +45 -0
- package/src/workflow/templates/diagnose.ts +58 -0
- package/src/workflow/templates/plan.ts +52 -0
- package/src/workflow/templates/ship.ts +64 -0
- package/src/buddy/reactions.ts +0 -41
- package/src/buddy/types.ts +0 -30
- package/src/config.ts +0 -19
- package/src/features/checkpoints.ts +0 -128
- package/src/features/sandbox.ts +0 -104
- package/src/validate.ts +0 -197
package/src/features/guard.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
+
import { isAbsolute, relative } from "node:path"
|
|
1
2
|
import type { FeatureModule } from "../types.ts"
|
|
2
3
|
import { inject, toast } from "../lib/inject.ts"
|
|
4
|
+
import { execBash, redactSensitiveOutput, sanitizeEnv } from "../lib/exec.ts"
|
|
3
5
|
|
|
4
6
|
export interface GuardHook {
|
|
5
7
|
name: string
|
|
6
8
|
tools: string[]
|
|
7
9
|
pathFilter?: string
|
|
10
|
+
glob?: Bun.Glob
|
|
8
11
|
run: string
|
|
9
12
|
mode: "inject" | "append"
|
|
10
13
|
debounceMs: number
|
|
@@ -14,6 +17,285 @@ export interface GuardHook {
|
|
|
14
17
|
maxDeferMs: number
|
|
15
18
|
}
|
|
16
19
|
|
|
20
|
+
export const EDIT_ERROR_PATTERNS = [
|
|
21
|
+
"oldstring and newstring must be different",
|
|
22
|
+
"oldstring not found",
|
|
23
|
+
"found multiple matches for oldstring",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
export const EDIT_RECOVERY_HINT =
|
|
27
|
+
"\n\n[edit recovery hint]\nThe edit failed due to a content mismatch. Use the `read` tool to inspect the latest file state around the target lines before retrying the edit."
|
|
28
|
+
|
|
29
|
+
export interface FloorGuardOptions {
|
|
30
|
+
allowSkips?: boolean
|
|
31
|
+
allowSuppressions?: boolean
|
|
32
|
+
allowAssertionRemoval?: boolean
|
|
33
|
+
[key: string]: unknown
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FloorViolationPattern {
|
|
37
|
+
name: string
|
|
38
|
+
pattern: RegExp
|
|
39
|
+
description: string
|
|
40
|
+
testFileOnly?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const FLOOR_VIOLATIONS: FloorViolationPattern[] = [
|
|
44
|
+
{
|
|
45
|
+
name: "test-skip-js",
|
|
46
|
+
pattern:
|
|
47
|
+
/(?:^|[.\s])(?:skip\s*\(|xit\s*\(|xdescribe\s*\()|\b(?:test\.skip|it\.skip|describe\.skip)\b/,
|
|
48
|
+
description: "Skipping test execution (.skip / xit / xdescribe)",
|
|
49
|
+
testFileOnly: true,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "test-skip-python",
|
|
53
|
+
pattern: /@pytest\.mark\.skip|@unittest\.skip/,
|
|
54
|
+
description: "Skipping test execution (@pytest.mark.skip / @unittest.skip)",
|
|
55
|
+
testFileOnly: true,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "test-skip-go",
|
|
59
|
+
pattern: /\bt\.Skip(?:\(|f\()/,
|
|
60
|
+
description: "Skipping test execution (t.Skip)",
|
|
61
|
+
testFileOnly: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
name: "test-skip-rust",
|
|
65
|
+
pattern: /#\[ignore(?:\s*=.*)?\]/,
|
|
66
|
+
description: "Skipping test execution (#[ignore])",
|
|
67
|
+
testFileOnly: true,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: "ts-suppression",
|
|
71
|
+
pattern:
|
|
72
|
+
/\/\/\s*@ts-(?:ignore|nocheck)\b|\/\*\s*@ts-(?:ignore|nocheck)\s*\*\/|\{\s*\/\*\s*@ts-(?:ignore|nocheck)\s*\*\/\s*\}/,
|
|
73
|
+
description: "TypeScript error suppression (@ts-ignore / @ts-nocheck)",
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "eslint-suppression",
|
|
77
|
+
pattern: /\/\*?\s*eslint-disable(?:-next-line)?\b/,
|
|
78
|
+
description: "ESLint diagnostic suppression (eslint-disable)",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "python-suppression",
|
|
82
|
+
pattern: /#\s*(?:noqa|type:\s*ignore)\b/,
|
|
83
|
+
description: "Python diagnostic suppression (# noqa / # type: ignore)",
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
name: "empty-catch",
|
|
87
|
+
pattern: /catch\s*(?:\([^)]*\))?\s*\{\s*\}/,
|
|
88
|
+
description: "Empty catch block swallowing errors silently",
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
|
|
92
|
+
export const ASSERTION_PATTERN =
|
|
93
|
+
/\b(?:expect\s*\(|assert\b|assert\.[a-zA-Z]+|assertEquals|assertTrue|assertFalse|self\.assert)/
|
|
94
|
+
|
|
95
|
+
export function isTestFile(filePath: string): boolean {
|
|
96
|
+
const normalized = filePath.toLowerCase().replaceAll("\\", "/")
|
|
97
|
+
const segments = normalized.split("/")
|
|
98
|
+
const filename = segments[segments.length - 1] ?? ""
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
filename.includes(".test.") ||
|
|
102
|
+
filename.includes(".spec.") ||
|
|
103
|
+
segments.includes("test") ||
|
|
104
|
+
segments.includes("tests") ||
|
|
105
|
+
segments.includes("__tests__") ||
|
|
106
|
+
filename.endsWith("_test.go") ||
|
|
107
|
+
filename.endsWith("_test.py") ||
|
|
108
|
+
filename.endsWith("_spec.rb") ||
|
|
109
|
+
filename.startsWith("test_")
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function checkFloorViolation(
|
|
114
|
+
tool: string,
|
|
115
|
+
args: Record<string, unknown> | undefined,
|
|
116
|
+
options: FloorGuardOptions = {},
|
|
117
|
+
): string | null {
|
|
118
|
+
const toolLower = tool.toLowerCase()
|
|
119
|
+
if (toolLower !== "edit" && toolLower !== "write") return null
|
|
120
|
+
if (!args) return null
|
|
121
|
+
|
|
122
|
+
const filePath =
|
|
123
|
+
typeof args.filePath === "string"
|
|
124
|
+
? args.filePath
|
|
125
|
+
: typeof args.path === "string"
|
|
126
|
+
? args.path
|
|
127
|
+
: typeof args.file_path === "string"
|
|
128
|
+
? args.file_path
|
|
129
|
+
: ""
|
|
130
|
+
|
|
131
|
+
const isTest = isTestFile(filePath)
|
|
132
|
+
|
|
133
|
+
if (toolLower === "edit") {
|
|
134
|
+
const oldStr = typeof args.oldString === "string" ? args.oldString : ""
|
|
135
|
+
const newStr = typeof args.newString === "string" ? args.newString : ""
|
|
136
|
+
|
|
137
|
+
for (const v of FLOOR_VIOLATIONS) {
|
|
138
|
+
if (v.testFileOnly && !isTest) continue
|
|
139
|
+
if (v.testFileOnly && options.allowSkips) continue
|
|
140
|
+
if (!v.testFileOnly && options.allowSuppressions) continue
|
|
141
|
+
|
|
142
|
+
if (v.pattern.test(newStr) && !v.pattern.test(oldStr)) {
|
|
143
|
+
return `Detected ${v.description} in ${filePath || "edited file"}. Modifying code to bypass tests or suppress warnings is prohibited by floor-guard policy. Fix the underlying issue instead.`
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!options.allowAssertionRemoval && isTest) {
|
|
148
|
+
if (
|
|
149
|
+
ASSERTION_PATTERN.test(oldStr) &&
|
|
150
|
+
!ASSERTION_PATTERN.test(newStr) &&
|
|
151
|
+
newStr.trim().length > 0
|
|
152
|
+
) {
|
|
153
|
+
return `Stripped test assertion(s) from ${filePath || "test file"} without replacement. Deleting assertions to make tests pass is prohibited by floor-guard policy. Fix the implementation to satisfy the assertion.`
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} else if (toolLower === "write") {
|
|
157
|
+
const content = typeof args.content === "string" ? args.content : ""
|
|
158
|
+
|
|
159
|
+
for (const v of FLOOR_VIOLATIONS) {
|
|
160
|
+
if (v.testFileOnly && !isTest) continue
|
|
161
|
+
if (v.testFileOnly && options.allowSkips) continue
|
|
162
|
+
if (!v.testFileOnly && options.allowSuppressions) continue
|
|
163
|
+
|
|
164
|
+
if (v.pattern.test(content)) {
|
|
165
|
+
return `Detected ${v.description} in ${filePath || "written file"}. Introducing test skips or error suppressions is prohibited by floor-guard policy.`
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function checkEditFailure(tool: string, outputText: string): string | null {
|
|
174
|
+
if (tool.toLowerCase() !== "edit") return null
|
|
175
|
+
const lower = outputText.toLowerCase()
|
|
176
|
+
if (EDIT_ERROR_PATTERNS.some((p) => lower.includes(p))) {
|
|
177
|
+
return EDIT_RECOVERY_HINT
|
|
178
|
+
}
|
|
179
|
+
return null
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export const GUARD_RECIPES: Record<string, Omit<GuardHook, "glob">> = {
|
|
183
|
+
tsc: {
|
|
184
|
+
name: "tsc",
|
|
185
|
+
tools: ["edit", "write"],
|
|
186
|
+
pathFilter: "**/*.{ts,tsx}",
|
|
187
|
+
run: "bun x tsc --noEmit || npx tsc --noEmit",
|
|
188
|
+
mode: "inject",
|
|
189
|
+
debounceMs: 2000,
|
|
190
|
+
timeoutMs: 60000,
|
|
191
|
+
onSuccess: "silent",
|
|
192
|
+
maxDeferMs: 300000,
|
|
193
|
+
},
|
|
194
|
+
eslint: {
|
|
195
|
+
name: "eslint",
|
|
196
|
+
tools: ["edit", "write"],
|
|
197
|
+
pathFilter: "**/*.{js,jsx,ts,tsx}",
|
|
198
|
+
run: "bun x eslint . || npx eslint .",
|
|
199
|
+
mode: "inject",
|
|
200
|
+
debounceMs: 2000,
|
|
201
|
+
timeoutMs: 60000,
|
|
202
|
+
onSuccess: "silent",
|
|
203
|
+
maxDeferMs: 300000,
|
|
204
|
+
},
|
|
205
|
+
ruff: {
|
|
206
|
+
name: "ruff",
|
|
207
|
+
tools: ["edit", "write"],
|
|
208
|
+
pathFilter: "**/*.py",
|
|
209
|
+
run: "ruff check .",
|
|
210
|
+
mode: "inject",
|
|
211
|
+
debounceMs: 2000,
|
|
212
|
+
timeoutMs: 60000,
|
|
213
|
+
onSuccess: "silent",
|
|
214
|
+
maxDeferMs: 300000,
|
|
215
|
+
},
|
|
216
|
+
cargo: {
|
|
217
|
+
name: "cargo",
|
|
218
|
+
tools: ["edit", "write"],
|
|
219
|
+
pathFilter: "**/*.rs",
|
|
220
|
+
run: "cargo check",
|
|
221
|
+
mode: "inject",
|
|
222
|
+
debounceMs: 2000,
|
|
223
|
+
timeoutMs: 60000,
|
|
224
|
+
onSuccess: "silent",
|
|
225
|
+
maxDeferMs: 300000,
|
|
226
|
+
},
|
|
227
|
+
go: {
|
|
228
|
+
name: "go",
|
|
229
|
+
tools: ["edit", "write"],
|
|
230
|
+
pathFilter: "**/*.go",
|
|
231
|
+
run: "go test ./...",
|
|
232
|
+
mode: "inject",
|
|
233
|
+
debounceMs: 2000,
|
|
234
|
+
timeoutMs: 60000,
|
|
235
|
+
onSuccess: "silent",
|
|
236
|
+
maxDeferMs: 300000,
|
|
237
|
+
},
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function detectRecipes(directory: string): Promise<GuardHook[]> {
|
|
241
|
+
const detected: GuardHook[] = []
|
|
242
|
+
if (await Bun.file(`${directory}/tsconfig.json`).exists()) {
|
|
243
|
+
detected.push({ ...GUARD_RECIPES.tsc, glob: new Bun.Glob("**/*.{ts,tsx}") })
|
|
244
|
+
}
|
|
245
|
+
if (await Bun.file(`${directory}/Cargo.toml`).exists()) {
|
|
246
|
+
detected.push({ ...GUARD_RECIPES.cargo, glob: new Bun.Glob("**/*.rs") })
|
|
247
|
+
}
|
|
248
|
+
if (
|
|
249
|
+
(await Bun.file(`${directory}/pyproject.toml`).exists()) ||
|
|
250
|
+
(await Bun.file(`${directory}/ruff.toml`).exists())
|
|
251
|
+
) {
|
|
252
|
+
detected.push({ ...GUARD_RECIPES.ruff, glob: new Bun.Glob("**/*.py") })
|
|
253
|
+
}
|
|
254
|
+
if (await Bun.file(`${directory}/go.mod`).exists()) {
|
|
255
|
+
detected.push({ ...GUARD_RECIPES.go, glob: new Bun.Glob("**/*.go") })
|
|
256
|
+
}
|
|
257
|
+
return detected
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Patterns matching dangerous command idioms that are inappropriate for quality-gate hooks,
|
|
262
|
+
* such as reverse shells, remote script piping, and arbitrary socket relays.
|
|
263
|
+
*/
|
|
264
|
+
export const DANGEROUS_COMMAND_PATTERNS: { pattern: RegExp; reason: string }[] = [
|
|
265
|
+
{ pattern: /\/dev\/(?:tcp|udp)\//i, reason: "network socket redirection (/dev/tcp or /dev/udp)" },
|
|
266
|
+
{ pattern: /\bmkfifo\b/i, reason: "named pipe creation (mkfifo)" },
|
|
267
|
+
{
|
|
268
|
+
pattern: /\b(?:nc|netcat)\b.*(?:\s+-e\s+|\s+-c\s+)/i,
|
|
269
|
+
reason: "netcat remote command execution flag (-e/-c)",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
pattern: /\b(?:curl|wget|fetch)\b.*\|\s*(?:bash|sh|zsh|python|perl|ruby)\b/i,
|
|
273
|
+
reason: "remote script execution via pipe (curl/wget | shell)",
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
pattern: /\bbase64\s+(?:-d|--decode)\b.*\|\s*(?:bash|sh|zsh)\b/i,
|
|
277
|
+
reason: "encoded payload execution via pipe (base64 -d | shell)",
|
|
278
|
+
},
|
|
279
|
+
{ pattern: /\bbash\s+-i\b.*>&/i, reason: "interactive reverse shell redirection (bash -i >&)" },
|
|
280
|
+
{ pattern: /\bsocat\s+/i, reason: "socket relay execution (socat)" },
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Validates a hook command string against dangerous patterns.
|
|
285
|
+
* Returns the rejection reason or null if valid.
|
|
286
|
+
*/
|
|
287
|
+
export function validateHookCommand(command: string): string | null {
|
|
288
|
+
for (const { pattern, reason } of DANGEROUS_COMMAND_PATTERNS) {
|
|
289
|
+
if (pattern.test(command)) {
|
|
290
|
+
return reason
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Maximum character length of failure payload tail to prevent prompt flooding. */
|
|
297
|
+
export const MAX_FAILURE_PAYLOAD_CHARS = 4000
|
|
298
|
+
|
|
17
299
|
/** options.hooks -> validated GuardHook[]. Invalid entries -> console.warn, skipped, never throw. */
|
|
18
300
|
export function parseHooks(raw: unknown): GuardHook[] {
|
|
19
301
|
if (!Array.isArray(raw)) return []
|
|
@@ -26,38 +308,78 @@ export function parseHooks(raw: unknown): GuardHook[] {
|
|
|
26
308
|
console.warn(`[overclock] guard: skipping invalid hook config: ${JSON.stringify(entry)}`)
|
|
27
309
|
continue
|
|
28
310
|
}
|
|
311
|
+
|
|
312
|
+
const danger = validateHookCommand(e.run)
|
|
313
|
+
if (danger) {
|
|
314
|
+
console.warn(`[overclock] guard: rejecting unsafe hook "${e.name}": ${danger}`)
|
|
315
|
+
continue
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const pathFilter = typeof e.pathFilter === "string" ? e.pathFilter : undefined
|
|
319
|
+
if (pathFilter && pathFilter.includes("..")) {
|
|
320
|
+
console.warn(`[overclock] guard: rejecting hook "${e.name}": pathFilter cannot contain ".."`)
|
|
321
|
+
continue
|
|
322
|
+
}
|
|
323
|
+
|
|
29
324
|
hooks.push({
|
|
30
325
|
name: e.name,
|
|
31
326
|
tools: e.tools as string[],
|
|
32
|
-
pathFilter
|
|
327
|
+
pathFilter,
|
|
328
|
+
glob: pathFilter ? new Bun.Glob(pathFilter) : undefined,
|
|
33
329
|
run: e.run,
|
|
34
330
|
mode: e.mode === "append" ? "append" : "inject",
|
|
35
|
-
debounceMs: typeof e.debounceMs === "number" ? e.debounceMs : 2000,
|
|
36
|
-
timeoutMs: typeof e.timeoutMs === "number" ? e.timeoutMs : 60000,
|
|
331
|
+
debounceMs: typeof e.debounceMs === "number" ? Math.max(50, Math.min(e.debounceMs, 60000)) : 2000,
|
|
332
|
+
timeoutMs: typeof e.timeoutMs === "number" ? Math.max(100, Math.min(e.timeoutMs, 300000)) : 60000,
|
|
37
333
|
onSuccess: e.onSuccess === "notify" ? "notify" : "silent",
|
|
38
|
-
maxDeferMs:
|
|
334
|
+
maxDeferMs:
|
|
335
|
+
typeof e.maxDeferMs === "number" ? Math.max(1000, Math.min(e.maxDeferMs, 600000)) : 300000,
|
|
39
336
|
})
|
|
40
337
|
}
|
|
41
338
|
return hooks
|
|
42
339
|
}
|
|
43
340
|
|
|
44
|
-
/** tools: exact match. pathFilter set + no filePath -> no match. */
|
|
45
|
-
export function matchHook(
|
|
46
|
-
|
|
341
|
+
/** tools: exact match or mapped name. pathFilter set + no filePath -> no match. */
|
|
342
|
+
export function matchHook(
|
|
343
|
+
hook: GuardHook,
|
|
344
|
+
toolName: string,
|
|
345
|
+
filePath: string | undefined,
|
|
346
|
+
resolveTool?: (name: string) => string,
|
|
347
|
+
cwd?: string,
|
|
348
|
+
): boolean {
|
|
349
|
+
const matches = hook.tools.some((t) => {
|
|
350
|
+
if (t === toolName) return true
|
|
351
|
+
if (resolveTool && resolveTool(t) === toolName) return true
|
|
352
|
+
return false
|
|
353
|
+
})
|
|
354
|
+
if (!matches) return false
|
|
47
355
|
if (!hook.pathFilter) return true
|
|
48
356
|
if (typeof filePath !== "string") return false
|
|
49
|
-
|
|
357
|
+
|
|
358
|
+
const glob = hook.glob ?? new Bun.Glob(hook.pathFilter)
|
|
359
|
+
const normalized = filePath.replaceAll("\\", "/")
|
|
360
|
+
if (glob.match(normalized)) return true
|
|
361
|
+
|
|
362
|
+
if (cwd && isAbsolute(normalized)) {
|
|
363
|
+
const rel = relative(cwd, normalized).replaceAll("\\", "/")
|
|
364
|
+
if (glob.match(rel)) return true
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return false
|
|
50
368
|
}
|
|
51
369
|
|
|
52
|
-
/** `[guard "<name>" failed (exit <code>)]` + last 40 lines of combined stdout+stderr. */
|
|
370
|
+
/** `[guard "<name>" failed (exit <code>)]` + sanitized last 40 lines of combined stdout+stderr. */
|
|
53
371
|
export function failurePayload(name: string, code: number | null, combined: string): string {
|
|
54
|
-
const
|
|
372
|
+
const sanitized = redactSensitiveOutput(combined)
|
|
373
|
+
let tail = sanitized.split("\n").slice(-40).join("\n")
|
|
374
|
+
if (tail.length > MAX_FAILURE_PAYLOAD_CHARS) {
|
|
375
|
+
tail = tail.slice(-MAX_FAILURE_PAYLOAD_CHARS) + "\n... [truncated for security & length]"
|
|
376
|
+
}
|
|
55
377
|
return `\n\n[guard "${name}" failed (exit ${code})]\n${tail}`
|
|
56
378
|
}
|
|
57
379
|
|
|
58
380
|
function buildEnv(toolName: string, filePath: string | undefined): Record<string, string | undefined> {
|
|
59
381
|
return {
|
|
60
|
-
...process.env,
|
|
382
|
+
...sanitizeEnv(process.env),
|
|
61
383
|
GUARD_TOOL: toolName,
|
|
62
384
|
...(filePath !== undefined ? { GUARD_FILE: filePath } : {}),
|
|
63
385
|
}
|
|
@@ -69,16 +391,13 @@ async function runCommand(
|
|
|
69
391
|
env: Record<string, string | undefined>,
|
|
70
392
|
register?: (proc: Bun.Subprocess) => void,
|
|
71
393
|
): Promise<{ code: number | null; combined: string }> {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
])
|
|
80
|
-
clearTimeout(killTimer)
|
|
81
|
-
return { code, combined: out + err }
|
|
394
|
+
return execBash(hook.run, {
|
|
395
|
+
cwd,
|
|
396
|
+
env,
|
|
397
|
+
timeoutMs: hook.timeoutMs,
|
|
398
|
+
onSpawn: register,
|
|
399
|
+
sanitizeEnv: true,
|
|
400
|
+
})
|
|
82
401
|
}
|
|
83
402
|
|
|
84
403
|
interface HookState {
|
|
@@ -200,7 +519,17 @@ export function createGuardRunner(deps: GuardRunnerDeps): GuardRunner {
|
|
|
200
519
|
function dispose(): void {
|
|
201
520
|
for (const s of states.values()) {
|
|
202
521
|
if (s.timer) clearTimeout(s.timer)
|
|
203
|
-
for (const p of s.procs)
|
|
522
|
+
for (const p of s.procs) {
|
|
523
|
+
try {
|
|
524
|
+
p.kill("SIGTERM")
|
|
525
|
+
const hard = setTimeout(() => {
|
|
526
|
+
try {
|
|
527
|
+
p.kill("SIGKILL")
|
|
528
|
+
} catch {}
|
|
529
|
+
}, 1000)
|
|
530
|
+
hard.unref?.()
|
|
531
|
+
} catch {}
|
|
532
|
+
}
|
|
204
533
|
}
|
|
205
534
|
}
|
|
206
535
|
|
|
@@ -218,7 +547,6 @@ export function createGuardRunner(deps: GuardRunnerDeps): GuardRunner {
|
|
|
218
547
|
export const guard: FeatureModule = {
|
|
219
548
|
name: "guard",
|
|
220
549
|
tools: [],
|
|
221
|
-
options: { hooks: "array" },
|
|
222
550
|
defaultEnabled: true,
|
|
223
551
|
requires: ["session.promptAsync", "session.messages"],
|
|
224
552
|
async init(ctx, options, shared) {
|
|
@@ -226,29 +554,85 @@ export const guard: FeatureModule = {
|
|
|
226
554
|
console.warn(`[overclock] guard: options.hooks must be an array, got ${typeof options.hooks}`)
|
|
227
555
|
}
|
|
228
556
|
const hooks = parseHooks(options.hooks)
|
|
229
|
-
if (
|
|
557
|
+
if (Array.isArray(options.recipes)) {
|
|
558
|
+
for (const r of options.recipes) {
|
|
559
|
+
if (typeof r === "string" && GUARD_RECIPES[r]) {
|
|
560
|
+
const recipe = GUARD_RECIPES[r]
|
|
561
|
+
hooks.push({
|
|
562
|
+
...recipe,
|
|
563
|
+
glob: recipe.pathFilter ? new Bun.Glob(recipe.pathFilter) : undefined,
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
if (options.auto === true) {
|
|
569
|
+
const autoHooks = await detectRecipes(ctx.directory)
|
|
570
|
+
hooks.push(...autoHooks)
|
|
571
|
+
}
|
|
230
572
|
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
573
|
+
const floorGuardEnabled =
|
|
574
|
+
options.floorGuard === true ||
|
|
575
|
+
options.auto === true ||
|
|
576
|
+
(typeof options.floorGuard === "object" && options.floorGuard !== null)
|
|
577
|
+
const floorGuardOpts: FloorGuardOptions =
|
|
578
|
+
typeof options.floorGuard === "object" && options.floorGuard !== null
|
|
579
|
+
? (options.floorGuard as FloorGuardOptions)
|
|
580
|
+
: {}
|
|
581
|
+
|
|
582
|
+
const editRecovery =
|
|
583
|
+
options.editRecovery === true || (hooks.length > 0 && options.editRecovery !== false)
|
|
584
|
+
if (hooks.length === 0 && !editRecovery && !floorGuardEnabled) return {}
|
|
585
|
+
|
|
586
|
+
const runner =
|
|
587
|
+
hooks.length > 0
|
|
588
|
+
? createGuardRunner({
|
|
589
|
+
cwd: ctx.directory,
|
|
590
|
+
onInject: async (sessionID, payload) => {
|
|
591
|
+
await inject(ctx.client, sessionID, payload)
|
|
592
|
+
},
|
|
593
|
+
onNotify: async (hookName) => {
|
|
594
|
+
await toast(ctx.client, `guard "${hookName}" passed`, "success")
|
|
595
|
+
},
|
|
596
|
+
isBusy: (sessionID) => shared.busy.isBusy(sessionID),
|
|
597
|
+
})
|
|
598
|
+
: undefined
|
|
241
599
|
|
|
242
600
|
return {
|
|
243
601
|
dispose: async () => {
|
|
244
|
-
runner
|
|
602
|
+
runner?.dispose()
|
|
245
603
|
},
|
|
246
604
|
"tool.execute.after": async (input, output) => {
|
|
605
|
+
if (editRecovery && typeof output.output === "string") {
|
|
606
|
+
const hint = checkEditFailure(input.tool, output.output)
|
|
607
|
+
if (hint) output.output += hint
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (floorGuardEnabled && typeof output.output === "string") {
|
|
611
|
+
const violation = checkFloorViolation(
|
|
612
|
+
input.tool,
|
|
613
|
+
input.args as Record<string, unknown> | undefined,
|
|
614
|
+
floorGuardOpts,
|
|
615
|
+
)
|
|
616
|
+
if (violation) {
|
|
617
|
+
output.output += `\n\n[overclock floor-guard warning]\n${violation}`
|
|
618
|
+
void toast(ctx.client, "guard: floor-guard violation detected", "warning")
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
if (!runner || hooks.length === 0) return
|
|
623
|
+
|
|
247
624
|
const args = input.args as Record<string, unknown> | undefined
|
|
248
|
-
const filePath =
|
|
625
|
+
const filePath =
|
|
626
|
+
typeof args?.filePath === "string"
|
|
627
|
+
? args.filePath
|
|
628
|
+
: typeof args?.path === "string"
|
|
629
|
+
? args.path
|
|
630
|
+
: typeof args?.file_path === "string"
|
|
631
|
+
? args.file_path
|
|
632
|
+
: undefined
|
|
249
633
|
|
|
250
634
|
for (const hook of hooks) {
|
|
251
|
-
if (!matchHook(hook, input.tool, filePath)) continue
|
|
635
|
+
if (!matchHook(hook, input.tool, filePath, shared?.toolName, ctx.directory)) continue
|
|
252
636
|
if (hook.mode === "append") {
|
|
253
637
|
const payload = await runner.runAppend(hook, input.tool, filePath)
|
|
254
638
|
if (payload && typeof output.output === "string") output.output += payload
|
package/src/features/index.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
import type { FeatureModule } from "../types.ts"
|
|
2
|
+
import { safety } from "./safety.ts"
|
|
3
|
+
import { workflow } from "./workflow.ts"
|
|
2
4
|
import { tasks } from "./tasks.ts"
|
|
3
5
|
import { sched } from "./sched.ts"
|
|
4
|
-
import { sandbox } from "./sandbox.ts"
|
|
5
6
|
import { guard } from "./guard.ts"
|
|
6
7
|
import { usage } from "./usage.ts"
|
|
7
|
-
import { checkpoints } from "./checkpoints.ts"
|
|
8
8
|
import { buddy } from "./buddy.ts"
|
|
9
|
+
import { truncator } from "./truncator.ts"
|
|
10
|
+
import { recovery } from "./recovery.ts"
|
|
9
11
|
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Registry, ordered. Order = hook composition order.
|
|
14
|
+
*/
|
|
15
|
+
export const features: FeatureModule[] = [
|
|
16
|
+
safety,
|
|
17
|
+
workflow,
|
|
18
|
+
tasks,
|
|
19
|
+
sched,
|
|
20
|
+
guard,
|
|
21
|
+
usage,
|
|
22
|
+
buddy,
|
|
23
|
+
truncator,
|
|
24
|
+
recovery,
|
|
25
|
+
]
|