opencode-overclock 0.4.0 → 0.5.1
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 +72 -17
- package/package.json +5 -3
- 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/core/lifecycle.ts +18 -4
- package/src/core/types.ts +29 -0
- package/src/features/guard.ts +258 -12
- package/src/features/index.ts +13 -1
- package/src/features/recovery.ts +13 -3
- package/src/features/safety.ts +147 -0
- package/src/features/sched.ts +46 -10
- package/src/features/tasks.ts +87 -20
- package/src/features/truncator.ts +26 -9
- package/src/features/usage.ts +20 -0
- package/src/features/workflow.ts +270 -0
- package/src/lib/exec.ts +7 -1
- package/src/platform/process/exec.ts +252 -11
- package/src/platform/session/inject.ts +8 -1
- package/src/platform/storage/state.ts +26 -4
- package/src/v2/host.ts +4 -1
- package/src/workflow/agents/codebase-researcher.ts +27 -0
- package/src/workflow/agents/craftsman.ts +26 -0
- package/src/workflow/agents/design-explorer.ts +33 -0
- package/src/workflow/agents/doc-writer.ts +24 -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/features/guard.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { isAbsolute, relative } from "node:path"
|
|
1
2
|
import type { FeatureModule } from "../types.ts"
|
|
2
3
|
import { inject, toast } from "../lib/inject.ts"
|
|
3
|
-
import { execBash } from "../lib/exec.ts"
|
|
4
|
+
import { execBash, redactSensitiveOutput, sanitizeEnv } from "../lib/exec.ts"
|
|
4
5
|
|
|
5
6
|
export interface GuardHook {
|
|
6
7
|
name: string
|
|
@@ -25,6 +26,150 @@ export const EDIT_ERROR_PATTERNS = [
|
|
|
25
26
|
export const EDIT_RECOVERY_HINT =
|
|
26
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."
|
|
27
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
|
+
|
|
28
173
|
export function checkEditFailure(tool: string, outputText: string): string | null {
|
|
29
174
|
if (tool.toLowerCase() !== "edit") return null
|
|
30
175
|
const lower = outputText.toLowerCase()
|
|
@@ -112,6 +257,45 @@ export async function detectRecipes(directory: string): Promise<GuardHook[]> {
|
|
|
112
257
|
return detected
|
|
113
258
|
}
|
|
114
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
|
+
|
|
115
299
|
/** options.hooks -> validated GuardHook[]. Invalid entries -> console.warn, skipped, never throw. */
|
|
116
300
|
export function parseHooks(raw: unknown): GuardHook[] {
|
|
117
301
|
if (!Array.isArray(raw)) return []
|
|
@@ -124,7 +308,19 @@ export function parseHooks(raw: unknown): GuardHook[] {
|
|
|
124
308
|
console.warn(`[overclock] guard: skipping invalid hook config: ${JSON.stringify(entry)}`)
|
|
125
309
|
continue
|
|
126
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
|
+
|
|
127
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
|
+
|
|
128
324
|
hooks.push({
|
|
129
325
|
name: e.name,
|
|
130
326
|
tools: e.tools as string[],
|
|
@@ -132,10 +328,11 @@ export function parseHooks(raw: unknown): GuardHook[] {
|
|
|
132
328
|
glob: pathFilter ? new Bun.Glob(pathFilter) : undefined,
|
|
133
329
|
run: e.run,
|
|
134
330
|
mode: e.mode === "append" ? "append" : "inject",
|
|
135
|
-
debounceMs: typeof e.debounceMs === "number" ? e.debounceMs : 2000,
|
|
136
|
-
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,
|
|
137
333
|
onSuccess: e.onSuccess === "notify" ? "notify" : "silent",
|
|
138
|
-
maxDeferMs:
|
|
334
|
+
maxDeferMs:
|
|
335
|
+
typeof e.maxDeferMs === "number" ? Math.max(1000, Math.min(e.maxDeferMs, 600000)) : 300000,
|
|
139
336
|
})
|
|
140
337
|
}
|
|
141
338
|
return hooks
|
|
@@ -147,6 +344,7 @@ export function matchHook(
|
|
|
147
344
|
toolName: string,
|
|
148
345
|
filePath: string | undefined,
|
|
149
346
|
resolveTool?: (name: string) => string,
|
|
347
|
+
cwd?: string,
|
|
150
348
|
): boolean {
|
|
151
349
|
const matches = hook.tools.some((t) => {
|
|
152
350
|
if (t === toolName) return true
|
|
@@ -156,18 +354,32 @@ export function matchHook(
|
|
|
156
354
|
if (!matches) return false
|
|
157
355
|
if (!hook.pathFilter) return true
|
|
158
356
|
if (typeof filePath !== "string") return false
|
|
159
|
-
|
|
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
|
|
160
368
|
}
|
|
161
369
|
|
|
162
|
-
/** `[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. */
|
|
163
371
|
export function failurePayload(name: string, code: number | null, combined: string): string {
|
|
164
|
-
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
|
+
}
|
|
165
377
|
return `\n\n[guard "${name}" failed (exit ${code})]\n${tail}`
|
|
166
378
|
}
|
|
167
379
|
|
|
168
380
|
function buildEnv(toolName: string, filePath: string | undefined): Record<string, string | undefined> {
|
|
169
381
|
return {
|
|
170
|
-
...process.env,
|
|
382
|
+
...sanitizeEnv(process.env),
|
|
171
383
|
GUARD_TOOL: toolName,
|
|
172
384
|
...(filePath !== undefined ? { GUARD_FILE: filePath } : {}),
|
|
173
385
|
}
|
|
@@ -179,7 +391,13 @@ async function runCommand(
|
|
|
179
391
|
env: Record<string, string | undefined>,
|
|
180
392
|
register?: (proc: Bun.Subprocess) => void,
|
|
181
393
|
): Promise<{ code: number | null; combined: string }> {
|
|
182
|
-
return execBash(hook.run, {
|
|
394
|
+
return execBash(hook.run, {
|
|
395
|
+
cwd,
|
|
396
|
+
env,
|
|
397
|
+
timeoutMs: hook.timeoutMs,
|
|
398
|
+
onSpawn: register,
|
|
399
|
+
sanitizeEnv: true,
|
|
400
|
+
})
|
|
183
401
|
}
|
|
184
402
|
|
|
185
403
|
interface HookState {
|
|
@@ -352,9 +570,18 @@ export const guard: FeatureModule = {
|
|
|
352
570
|
hooks.push(...autoHooks)
|
|
353
571
|
}
|
|
354
572
|
|
|
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
|
+
|
|
355
582
|
const editRecovery =
|
|
356
583
|
options.editRecovery === true || (hooks.length > 0 && options.editRecovery !== false)
|
|
357
|
-
if (hooks.length === 0 && !editRecovery) return {}
|
|
584
|
+
if (hooks.length === 0 && !editRecovery && !floorGuardEnabled) return {}
|
|
358
585
|
|
|
359
586
|
const runner =
|
|
360
587
|
hooks.length > 0
|
|
@@ -380,13 +607,32 @@ export const guard: FeatureModule = {
|
|
|
380
607
|
if (hint) output.output += hint
|
|
381
608
|
}
|
|
382
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
|
+
|
|
383
622
|
if (!runner || hooks.length === 0) return
|
|
384
623
|
|
|
385
624
|
const args = input.args as Record<string, unknown> | undefined
|
|
386
|
-
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
|
|
387
633
|
|
|
388
634
|
for (const hook of hooks) {
|
|
389
|
-
if (!matchHook(hook, input.tool, filePath, shared?.toolName)) continue
|
|
635
|
+
if (!matchHook(hook, input.tool, filePath, shared?.toolName, ctx.directory)) continue
|
|
390
636
|
if (hook.mode === "append") {
|
|
391
637
|
const payload = await runner.runAppend(hook, input.tool, filePath)
|
|
392
638
|
if (payload && typeof output.output === "string") output.output += payload
|
package/src/features/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
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
6
|
import { guard } from "./guard.ts"
|
|
@@ -10,4 +12,14 @@ import { recovery } from "./recovery.ts"
|
|
|
10
12
|
/**
|
|
11
13
|
* Registry, ordered. Order = hook composition order.
|
|
12
14
|
*/
|
|
13
|
-
export const features: FeatureModule[] = [
|
|
15
|
+
export const features: FeatureModule[] = [
|
|
16
|
+
safety,
|
|
17
|
+
workflow,
|
|
18
|
+
tasks,
|
|
19
|
+
sched,
|
|
20
|
+
guard,
|
|
21
|
+
usage,
|
|
22
|
+
buddy,
|
|
23
|
+
truncator,
|
|
24
|
+
recovery,
|
|
25
|
+
]
|
package/src/features/recovery.ts
CHANGED
|
@@ -68,7 +68,11 @@ export function createRecoveryTracker(maxAttempts = 3, cooldownMs = 60_000): Rec
|
|
|
68
68
|
canAttempt(sessionID: string): boolean {
|
|
69
69
|
const now = Date.now()
|
|
70
70
|
const list = (attempts.get(sessionID) ?? []).filter((t) => now - t < cooldownMs)
|
|
71
|
-
|
|
71
|
+
if (list.length === 0) {
|
|
72
|
+
attempts.delete(sessionID)
|
|
73
|
+
} else {
|
|
74
|
+
attempts.set(sessionID, list)
|
|
75
|
+
}
|
|
72
76
|
return list.length < maxAttempts
|
|
73
77
|
},
|
|
74
78
|
recordAttempt(sessionID: string): void {
|
|
@@ -101,8 +105,9 @@ export const recovery: FeatureModule = {
|
|
|
101
105
|
return {
|
|
102
106
|
event: async ({ event }) => {
|
|
103
107
|
if (event.type === "session.deleted") {
|
|
104
|
-
const props = event.properties as { info?: { id?: string } } | undefined
|
|
105
|
-
|
|
108
|
+
const props = event.properties as { sessionID?: string; info?: { id?: string } } | undefined
|
|
109
|
+
const id = props?.sessionID ?? props?.info?.id
|
|
110
|
+
if (id) tracker.reset(id)
|
|
106
111
|
return
|
|
107
112
|
}
|
|
108
113
|
|
|
@@ -130,6 +135,11 @@ export const recovery: FeatureModule = {
|
|
|
130
135
|
await toast(ctx.client, `recovering session from ${classified.reason}...`, "warning")
|
|
131
136
|
|
|
132
137
|
if (autoResume) {
|
|
138
|
+
if (classified.reason === "rate_limit") {
|
|
139
|
+
// Apply a brief backoff delay before retrying a throttled endpoint
|
|
140
|
+
await new Promise((r) => setTimeout(r, 2000))
|
|
141
|
+
}
|
|
142
|
+
|
|
133
143
|
const resumeText =
|
|
134
144
|
classified.reason === "context_limit"
|
|
135
145
|
? "[session recovered: context limit reached; summarize recent progress and continue with minimal output]"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { FeatureModule } from "../types.ts"
|
|
2
|
+
import { toast } from "../lib/inject.ts"
|
|
3
|
+
import { shellQuote } from "../lib/exec.ts"
|
|
4
|
+
|
|
5
|
+
export interface DangerousPattern {
|
|
6
|
+
name: string
|
|
7
|
+
pattern: RegExp
|
|
8
|
+
reason: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const GIT_PREFIX =
|
|
12
|
+
"\\bgit(?:\\s+(?:-[a-zA-Z0-9_.-]+|--[a-zA-Z0-9_.-]+(?:=\\S+)?|-[a-zA-Z]\\s+(?:\"[^\"]*\"|'[^']*'|\\S+)|--(?:git-dir|work-tree|namespace)\\s+(?:\"[^\"]*\"|'[^']*'|\\S+)))*\\s+"
|
|
13
|
+
|
|
14
|
+
export const DANGEROUS_GIT_PATTERNS: DangerousPattern[] = [
|
|
15
|
+
{
|
|
16
|
+
name: "force-push",
|
|
17
|
+
pattern: new RegExp(
|
|
18
|
+
`${GIT_PREFIX}push\\b[^;&|\\n]*(?:\\s(?:--force(?:-with-lease)?\\b|-f\\b)|\\s\\+[^\\s:]+(?::\\S+)?)`,
|
|
19
|
+
),
|
|
20
|
+
reason: "Force-pushing can overwrite remote history.",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: "hard-reset",
|
|
24
|
+
pattern: new RegExp(`${GIT_PREFIX}reset\\b[^;&|\\n]*(?:\\s|^)--hard\\b`),
|
|
25
|
+
reason: "Hard-reset discards uncommitted changes permanently.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "force-clean",
|
|
29
|
+
pattern: new RegExp(`${GIT_PREFIX}clean\\b[^;&|\\n]*(?:\\s-[a-zA-Z]*f[a-zA-Z]*|\\s--force\\b)`),
|
|
30
|
+
reason: "Force clean deletes untracked files irreversibly.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "branch-force-delete",
|
|
34
|
+
pattern: new RegExp(
|
|
35
|
+
`${GIT_PREFIX}branch\\b[^;&|\\n]*(?:\\s-[a-zA-Z]*D[a-zA-Z]*|\\s--delete\\s+--force\\b|\\s--force\\s+--delete\\b|\\s-[a-zA-Z]*d[a-zA-Z]*\\s+-[a-zA-Z]*f[a-zA-Z]*|\\s-[a-zA-Z]*f[a-zA-Z]*\\s+-[a-zA-Z]*d[a-zA-Z]*)`,
|
|
36
|
+
),
|
|
37
|
+
reason: "Force deleting a branch bypasses unmerged commit checks.",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "remote-branch-delete",
|
|
41
|
+
pattern: new RegExp(`${GIT_PREFIX}push\\b[^;&|\\n]*(?:\\s--delete\\b|\\s-d\\b|\\s:[\\w/.-]+)`),
|
|
42
|
+
reason: "Deleting a remote branch can impact other collaborators.",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "discard-all-worktree",
|
|
46
|
+
pattern: new RegExp(
|
|
47
|
+
`${GIT_PREFIX}(?:restore|checkout)\\b[^;&|\\n]*?(?:\\s(?:--\\s+)?(?:\\.|\\*|:\\/))(?=\\s|$|[;&|])`,
|
|
48
|
+
),
|
|
49
|
+
reason: "Discarding entire worktree changes loses all in-progress edits.",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "stash-destroy",
|
|
53
|
+
pattern: new RegExp(`${GIT_PREFIX}stash\\s+(?:drop|clear)\\b`),
|
|
54
|
+
reason: "Dropping or clearing stashes deletes saved work.",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: "rebase-skip",
|
|
58
|
+
pattern: new RegExp(`${GIT_PREFIX}rebase\\s+--skip\\b`),
|
|
59
|
+
reason: "Rebase skip drops the conflicting commit completely.",
|
|
60
|
+
},
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
export interface SafetyOptions {
|
|
64
|
+
blockDestructiveGit?: boolean
|
|
65
|
+
allowForcePush?: boolean
|
|
66
|
+
allowStashDrop?: boolean
|
|
67
|
+
customPatterns?: { name: string; pattern: string; reason: string }[]
|
|
68
|
+
[key: string]: unknown
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function resolvePatterns(options: SafetyOptions = {}): DangerousPattern[] {
|
|
72
|
+
let list = [...DANGEROUS_GIT_PATTERNS]
|
|
73
|
+
|
|
74
|
+
if (options.allowForcePush === true) {
|
|
75
|
+
list = list.filter((p) => p.name !== "force-push")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (options.allowStashDrop === true) {
|
|
79
|
+
list = list.filter((p) => p.name !== "stash-destroy")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (Array.isArray(options.customPatterns)) {
|
|
83
|
+
for (const c of options.customPatterns) {
|
|
84
|
+
if (typeof c.name === "string" && typeof c.pattern === "string") {
|
|
85
|
+
try {
|
|
86
|
+
list.push({
|
|
87
|
+
name: c.name,
|
|
88
|
+
pattern: new RegExp(c.pattern),
|
|
89
|
+
reason: typeof c.reason === "string" ? c.reason : "Blocked by custom safety policy.",
|
|
90
|
+
})
|
|
91
|
+
} catch (e) {
|
|
92
|
+
console.warn(`[overclock] safety: invalid custom pattern "${c.name}": ${e}`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return list
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function checkDangerousCommand(
|
|
102
|
+
command: string,
|
|
103
|
+
patterns: DangerousPattern[],
|
|
104
|
+
): DangerousPattern | null {
|
|
105
|
+
for (const p of patterns) {
|
|
106
|
+
if (p.pattern.test(command)) {
|
|
107
|
+
return p
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const safety: FeatureModule = {
|
|
114
|
+
name: "safety",
|
|
115
|
+
tools: [],
|
|
116
|
+
defaultEnabled: true,
|
|
117
|
+
async init(ctx, options, shared) {
|
|
118
|
+
const opts = (options ?? {}) as SafetyOptions
|
|
119
|
+
if (opts.blockDestructiveGit === false) {
|
|
120
|
+
return {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const patterns = resolvePatterns(opts)
|
|
124
|
+
const bashToolName = shared?.toolName ? shared.toolName("bash").toLowerCase() : "bash"
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
"tool.execute.before": async (input, output) => {
|
|
128
|
+
const toolLower = input.tool.toLowerCase()
|
|
129
|
+
if (toolLower !== "bash" && toolLower !== bashToolName) return
|
|
130
|
+
|
|
131
|
+
const args = output.args as Record<string, unknown> | undefined
|
|
132
|
+
if (!args || typeof args.command !== "string") return
|
|
133
|
+
|
|
134
|
+
const matched = checkDangerousCommand(args.command, patterns)
|
|
135
|
+
if (!matched) return
|
|
136
|
+
|
|
137
|
+
const blockedMessage = `[overclock safety] Blocked destructive git command (${matched.name}): ${matched.reason}\nCommand requested: ${args.command}\nAction: To prevent accidental code loss, destructive git actions are blocked by overclock safety policy. Use safe alternatives like 'git stash push', 'git revert', or selective file restore.`
|
|
138
|
+
|
|
139
|
+
// Rewrite command to exit with error rather than crashing the execution fiber
|
|
140
|
+
// Use shellQuote to ensure no shell expansion/substitution occurs on the blocked command
|
|
141
|
+
output.args.command = `printf '%s\\n' ${shellQuote(blockedMessage)} >&2 && exit 1`
|
|
142
|
+
|
|
143
|
+
void toast(ctx.client, `safety: blocked ${matched.name}`, "warning")
|
|
144
|
+
},
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}
|
package/src/features/sched.ts
CHANGED
|
@@ -11,12 +11,25 @@ const z = tool.schema
|
|
|
11
11
|
export type Spec = { kind: "interval"; ms: number } | { kind: "cron"; expr: string }
|
|
12
12
|
|
|
13
13
|
const UNITS: Record<string, number> = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }
|
|
14
|
+
export const MIN_INTERVAL_MS = 5000
|
|
15
|
+
export const MAX_SCHEDULES = 50
|
|
16
|
+
export const MAX_CONSECUTIVE_FAILURES = 5
|
|
14
17
|
|
|
15
|
-
/** "30s" | "5m" | "2h" | "1d" -> interval; else cron expr (validated). Throws on garbage. */
|
|
18
|
+
/** "30s" | "5m" | "2h" | "1d" -> interval; else cron expr (validated). Throws on garbage or frequency < 5s. */
|
|
16
19
|
export function parseSpec(spec: string): Spec {
|
|
17
20
|
const m = spec.trim().match(/^(\d+)([smhd])$/)
|
|
18
|
-
if (m)
|
|
19
|
-
|
|
21
|
+
if (m) {
|
|
22
|
+
const ms = Number(m[1]) * UNITS[m[2]!]!
|
|
23
|
+
if (ms < MIN_INTERVAL_MS) {
|
|
24
|
+
throw new Error(`Schedule interval must be at least 5s (got ${spec})`)
|
|
25
|
+
}
|
|
26
|
+
return { kind: "interval", ms }
|
|
27
|
+
}
|
|
28
|
+
const cron = new Cron(spec) // throws if invalid
|
|
29
|
+
const runs = cron.nextRuns(2)
|
|
30
|
+
if (runs.length >= 2 && runs[1]!.getTime() - runs[0]!.getTime() < MIN_INTERVAL_MS) {
|
|
31
|
+
throw new Error(`Schedule frequency must be at least 5s (got ${spec})`)
|
|
32
|
+
}
|
|
20
33
|
return { kind: "cron", expr: spec }
|
|
21
34
|
}
|
|
22
35
|
|
|
@@ -34,6 +47,7 @@ export interface ScheduleManager {
|
|
|
34
47
|
delete(id: string): Promise<boolean>
|
|
35
48
|
arm(s: ScheduleEntry): void
|
|
36
49
|
disarm(id: string): void
|
|
50
|
+
fire(s: ScheduleEntry): Promise<void>
|
|
37
51
|
nextRun(s: ScheduleEntry): string
|
|
38
52
|
dispose(): void
|
|
39
53
|
}
|
|
@@ -52,10 +66,19 @@ export interface ScheduleManagerDeps {
|
|
|
52
66
|
export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<ScheduleManager> {
|
|
53
67
|
const schedules = new Map<string, ScheduleEntry>()
|
|
54
68
|
const timers = new Map<string, { stop(): void }>()
|
|
69
|
+
const consecutiveFailures = new Map<string, number>()
|
|
55
70
|
const skipIfBusy = deps.skipIfBusy !== false
|
|
56
71
|
|
|
57
72
|
const persist = async () => writeJson(deps.storePath, [...schedules.values()])
|
|
58
73
|
|
|
74
|
+
async function remove(id: string): Promise<boolean> {
|
|
75
|
+
if (!schedules.delete(id)) return false
|
|
76
|
+
disarm(id)
|
|
77
|
+
consecutiveFailures.delete(id)
|
|
78
|
+
await persist()
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
59
82
|
async function fire(s: ScheduleEntry) {
|
|
60
83
|
try {
|
|
61
84
|
if (s.target === "current") {
|
|
@@ -64,7 +87,21 @@ export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<
|
|
|
64
87
|
return
|
|
65
88
|
}
|
|
66
89
|
const ok = await inject(deps.client, s.sessionID, `[schedule ${s.id} fired]\n${s.prompt}`)
|
|
67
|
-
if (!ok)
|
|
90
|
+
if (!ok) {
|
|
91
|
+
const fails = (consecutiveFailures.get(s.id) ?? 0) + 1
|
|
92
|
+
consecutiveFailures.set(s.id, fails)
|
|
93
|
+
if (fails >= MAX_CONSECUTIVE_FAILURES) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`[overclock] schedule ${s.id}: target session ${s.sessionID} unreachable ${fails} times, auto-removing`,
|
|
96
|
+
)
|
|
97
|
+
await toast(deps.client, `schedule ${s.id}: session unreachable, auto-removed`, "warning")
|
|
98
|
+
await remove(s.id)
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
await toast(deps.client, `schedule ${s.id}: target session gone`, "warning")
|
|
102
|
+
} else {
|
|
103
|
+
consecutiveFailures.delete(s.id)
|
|
104
|
+
}
|
|
68
105
|
} else {
|
|
69
106
|
const res = await deps.client.session.create({ body: { title: `sched:${s.id}` } })
|
|
70
107
|
const id = res.data?.id
|
|
@@ -117,8 +154,12 @@ export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<
|
|
|
117
154
|
schedules,
|
|
118
155
|
arm,
|
|
119
156
|
disarm,
|
|
157
|
+
fire,
|
|
120
158
|
nextRun,
|
|
121
159
|
async create(input) {
|
|
160
|
+
if (schedules.size >= MAX_SCHEDULES) {
|
|
161
|
+
throw new Error(`Maximum schedules limit reached (${MAX_SCHEDULES})`)
|
|
162
|
+
}
|
|
122
163
|
parseSpec(input.spec)
|
|
123
164
|
const s: ScheduleEntry = {
|
|
124
165
|
id: `s-${crypto.randomUUID().slice(0, 6)}`,
|
|
@@ -136,12 +177,7 @@ export async function createScheduleManager(deps: ScheduleManagerDeps): Promise<
|
|
|
136
177
|
list() {
|
|
137
178
|
return [...schedules.values()].map((s) => ({ schedule: s, next: nextRun(s) }))
|
|
138
179
|
},
|
|
139
|
-
|
|
140
|
-
if (!schedules.delete(id)) return false
|
|
141
|
-
disarm(id)
|
|
142
|
-
await persist()
|
|
143
|
-
return true
|
|
144
|
-
},
|
|
180
|
+
delete: remove,
|
|
145
181
|
dispose() {
|
|
146
182
|
for (const id of [...timers.keys()]) disarm(id)
|
|
147
183
|
},
|