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.
Files changed (82) hide show
  1. package/README.md +252 -111
  2. package/package.json +6 -4
  3. package/skills/codebase-design/DEEPENING.md +35 -0
  4. package/skills/codebase-design/DESIGN-IT-TWICE.md +34 -0
  5. package/skills/codebase-design/SKILL.md +93 -0
  6. package/skills/diagnosing-bugs/SKILL.md +123 -0
  7. package/skills/domain-modeling/ADR-FORMAT.md +55 -0
  8. package/skills/domain-modeling/CONTEXT-FORMAT.md +32 -0
  9. package/skills/domain-modeling/SKILL.md +102 -0
  10. package/skills/doubt/SKILL.md +80 -0
  11. package/skills/grilling/SKILL.md +96 -0
  12. package/skills/source-discipline/SKILL.md +78 -0
  13. package/skills/tdd/SKILL.md +87 -0
  14. package/skills/to-spec/SKILL.md +69 -0
  15. package/skills/to-spec/SPEC-TEMPLATE.md +50 -0
  16. package/skills/to-tickets/SKILL.md +74 -0
  17. package/skills/to-tickets/TICKET-TEMPLATE.md +41 -0
  18. package/src/bridge.ts +1 -0
  19. package/src/buddy/companion.ts +104 -5
  20. package/src/buddy/sprites.ts +4 -4
  21. package/src/buddy/tui.ts +175 -65
  22. package/src/core/bridge.ts +34 -0
  23. package/src/core/lifecycle.ts +67 -0
  24. package/src/core/policy.ts +128 -0
  25. package/src/core/summary.ts +33 -0
  26. package/src/core/types.ts +193 -0
  27. package/src/features/buddy.ts +1 -2
  28. package/src/features/guard.ts +421 -37
  29. package/src/features/index.ts +18 -4
  30. package/src/features/recovery.ts +153 -0
  31. package/src/features/safety.ts +147 -0
  32. package/src/features/sched.ts +183 -89
  33. package/src/features/tasks.ts +134 -33
  34. package/src/features/truncator.ts +116 -0
  35. package/src/features/usage.ts +46 -65
  36. package/src/features/workflow.ts +256 -0
  37. package/src/index.ts +96 -67
  38. package/src/lib/busy.ts +1 -25
  39. package/src/lib/exec.ts +13 -0
  40. package/src/lib/inject.ts +10 -56
  41. package/src/lib/mirror.ts +13 -0
  42. package/src/lib/probe.ts +1 -15
  43. package/src/lib/state.ts +10 -39
  44. package/src/lib/tmux.ts +1 -0
  45. package/src/lib/ui.ts +208 -0
  46. package/src/merge.ts +2 -66
  47. package/src/platform/probe.ts +25 -0
  48. package/src/platform/process/exec.ts +317 -0
  49. package/src/platform/process/tmux.ts +60 -0
  50. package/src/platform/session/busy.ts +33 -0
  51. package/src/platform/session/inject.ts +89 -0
  52. package/src/platform/session/notify.ts +20 -0
  53. package/src/platform/storage/state.ts +99 -0
  54. package/src/platform/storage/store.ts +61 -0
  55. package/src/summary.ts +1 -0
  56. package/src/tools.ts +8 -244
  57. package/src/tui.ts +57 -186
  58. package/src/types.ts +1 -73
  59. package/src/v2/context.ts +470 -0
  60. package/src/v2/host.ts +120 -0
  61. package/src/v2/loader.ts +150 -0
  62. package/src/workflow/agents/codebase-researcher.ts +27 -0
  63. package/src/workflow/agents/design-explorer.ts +33 -0
  64. package/src/workflow/agents/doubt-reviewer.ts +26 -0
  65. package/src/workflow/agents/engineering-coach.ts +23 -0
  66. package/src/workflow/agents/performance-auditor.ts +29 -0
  67. package/src/workflow/agents/security-auditor.ts +23 -0
  68. package/src/workflow/agents/spec-reviewer.ts +15 -0
  69. package/src/workflow/agents/standards-reviewer.ts +24 -0
  70. package/src/workflow/agents/test-engineer.ts +28 -0
  71. package/src/workflow/catalog.ts +210 -0
  72. package/src/workflow/templates/build.ts +47 -0
  73. package/src/workflow/templates/define.ts +45 -0
  74. package/src/workflow/templates/diagnose.ts +58 -0
  75. package/src/workflow/templates/plan.ts +52 -0
  76. package/src/workflow/templates/ship.ts +64 -0
  77. package/src/buddy/reactions.ts +0 -41
  78. package/src/buddy/types.ts +0 -30
  79. package/src/config.ts +0 -19
  80. package/src/features/checkpoints.ts +0 -128
  81. package/src/features/sandbox.ts +0 -104
  82. package/src/validate.ts +0 -197
@@ -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: typeof e.pathFilter === "string" ? e.pathFilter : undefined,
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: typeof e.maxDeferMs === "number" ? e.maxDeferMs : 300000,
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(hook: GuardHook, toolName: string, filePath: string | undefined): boolean {
46
- if (!hook.tools.includes(toolName)) return false
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
- return new Bun.Glob(hook.pathFilter).match(filePath)
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 tail = combined.split("\n").slice(-40).join("\n")
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
- const proc = Bun.spawn(["bash", "-c", hook.run], { cwd, env, stdout: "pipe", stderr: "pipe" })
73
- register?.(proc)
74
- const killTimer = setTimeout(() => proc.kill("SIGTERM"), hook.timeoutMs)
75
- const [out, err, code] = await Promise.all([
76
- new Response(proc.stdout).text(),
77
- new Response(proc.stderr).text(),
78
- proc.exited,
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) p.kill("SIGTERM")
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 (hooks.length === 0) return {}
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 runner = createGuardRunner({
232
- cwd: ctx.directory,
233
- onInject: async (sessionID, payload) => {
234
- await inject(ctx.client, sessionID, payload)
235
- },
236
- onNotify: async (hookName) => {
237
- await toast(ctx.client, `guard "${hookName}" passed`, "success")
238
- },
239
- isBusy: (sessionID) => shared.busy.isBusy(sessionID),
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.dispose()
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 = typeof args?.filePath === "string" ? args.filePath : undefined
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
@@ -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
- /** Registry, ordered. Order = hook composition order. */
11
- export const features: FeatureModule[] = [tasks, sched, sandbox, guard, usage, checkpoints, buddy]
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
+ ]