Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .opencode/hooks/enforce-factcheck-before-edit.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/bin/sh
# enforce-factcheck-before-edit.sh
# PreToolUse hook: warns when Write/Edit tools are invoked without evidence of prior research
# Exit 0 = pass (advisory only), stderr message becomes hook context

TOOL="$OPENCODE_TOOL_NAME"
INPUT="$OPENCODE_TOOL_INPUT"

# Only apply to write/edit tools
case "$TOOL" in
write|edit) ;;
*) exit 0 ;;
esac

# Check if tool input contains evidence of prior file reading
if printf '%s\n' "$INPUT" | grep -qiE '(based on reading|as shown in|from the file|grep result|read tool output|line [0-9]+)'; then
echo "FACT-CHECK: Evidence of prior research detected" >&2
Comment on lines +15 to +17
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using printf '%s' "$INPUT" instead of echo "$INPUT" for POSIX portability and to avoid any shell-specific echo edge cases (even though JSON usually starts with {).

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On evidence detected, the script emits a success message to stderr. Hook stderr is surfaced as HookResult.message even on pass, so this will add extra context/noise (and token usage) to every Write/Edit that contains any evidence phrase. Consider staying silent on success and only emitting stderr for warnings (or guard the success message behind a debug flag).

Suggested change
echo "FACT-CHECK: Evidence of prior research detected" >&2

Copilot uses AI. Check for mistakes.
exit 0
fi

echo "FACT-CHECK WARNING: File edit attempted without evidence of reading the target file first. Best practice: read the file before editing." >&2
exit 0
12 changes: 12 additions & 0 deletions .opencode/opencode.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,16 @@
"github-triage": false,
"github-pr-search": false,
},
"hooks": {
"PreToolUse": [
{
"command": ".opencode/hooks/enforce-factcheck-before-edit.sh",
"matcher": "write",
},
{
"command": ".opencode/hooks/enforce-factcheck-before-edit.sh",
Comment on lines +128 to +132
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hook command is a relative path and relies on the script being executable and the process CWD being the repo root. Since hooks are executed via sh -c, it’s more robust to invoke it explicitly (e.g., sh "$OPENCODE_PROJECT_DIR/.opencode/hooks/enforce-factcheck-before-edit.sh") so it works regardless of CWD/exec-bit preservation.

Suggested change
"command": ".opencode/hooks/enforce-factcheck-before-edit.sh",
"matcher": "write",
},
{
"command": ".opencode/hooks/enforce-factcheck-before-edit.sh",
"command": "sh \"$OPENCODE_PROJECT_DIR/.opencode/hooks/enforce-factcheck-before-edit.sh\"",
"matcher": "write",
},
{
"command": "sh \"$OPENCODE_PROJECT_DIR/.opencode/hooks/enforce-factcheck-before-edit.sh\"",

Copilot uses AI. Check for mistakes.
"matcher": "edit",
},
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PreToolUse hook only matches write/edit, but OpenCode can disable those tools in favor of apply_patch (and edits can also happen via multiedit). In those cases the fact-check hook won’t run at all, which undermines the intended coverage; consider adding matchers (and corresponding script support) for the other edit-capable tools.

Suggested change
},
},
{
"command": ".opencode/hooks/enforce-factcheck-before-edit.sh",
"matcher": "apply_patch",
},
{
"command": ".opencode/hooks/enforce-factcheck-before-edit.sh",
"matcher": "multiedit",
},

Copilot uses AI. Check for mistakes.
],
},
}
94 changes: 94 additions & 0 deletions packages/opencode/test/hook/factcheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import path from "node:path"
import { describe, expect, test } from "bun:test"
import { runHook, type HookEnv } from "../../src/hook"

const SCRIPT_PATH = path.resolve(
import.meta.dir,
"../../../../.opencode/hooks/enforce-factcheck-before-edit.sh",
)

function makeEnv(toolName: string, toolInput: string): HookEnv {
return {
OPENCODE_HOOK_EVENT: "PreToolUse",
OPENCODE_TOOL_NAME: toolName,
OPENCODE_TOOL_INPUT: toolInput,
OPENCODE_PROJECT_DIR: "/tmp/test",
OPENCODE_SESSION_ID: "test-session",
}
}

describe("enforce-factcheck-before-edit", () => {
test("passes non-edit tools without message", async () => {
const result = await runHook({ command: SCRIPT_PATH }, makeEnv("bash", "{}"))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})

test("passes non-edit tool 'read'", async () => {
const result = await runHook({ command: SCRIPT_PATH }, makeEnv("read", '{"file":"/tmp/x"}'))
expect(result.action).toBe("pass")
expect(result.message).toBeUndefined()
})

test("warns on Write without evidence", async () => {
const result = await runHook(
{ command: SCRIPT_PATH },
makeEnv("Write", '{"file_path":"/tmp/test.ts","content":"hello"}'),
)
expect(result.action).toBe("pass")
expect(result.message).toContain("FACT-CHECK WARNING")

Check failure on line 39 in packages/opencode/test/hook/factcheck.test.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

error: Received value must be an array type

at <anonymous> (/home/runner/work/opencode/opencode/packages/opencode/test/hook/factcheck.test.ts:39:28)
})

test("warns on edit without evidence", async () => {
const result = await runHook(
{ command: SCRIPT_PATH },
makeEnv("edit", '{"old_string":"foo","new_string":"bar"}'),
)
Comment on lines +33 to +46
Copy link

Copilot AI Apr 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests use snake_case tool input keys (e.g., file_path, old_string, new_string), but the actual tool schemas use camelCase (filePath, oldString, newString). Using the real shapes would make the tests less misleading and more resilient if the hook later inspects specific fields (like filePath).

Copilot uses AI. Check for mistakes.
expect(result.action).toBe("pass")
expect(result.message).toContain("FACT-CHECK WARNING")
})

test("passes Edit with evidence phrase 'based on reading'", async () => {
const result = await runHook(
{ command: SCRIPT_PATH },
makeEnv("Edit", '{"old_string":"based on reading the config"}'),
)
expect(result.action).toBe("pass")
expect(result.message).toContain("Evidence of prior research detected")

Check failure on line 57 in packages/opencode/test/hook/factcheck.test.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

error: Received value must be an array type

at <anonymous> (/home/runner/work/opencode/opencode/packages/opencode/test/hook/factcheck.test.ts:57:28)
})

test("passes Write with evidence phrase 'line 42'", async () => {
const result = await runHook(
{ command: SCRIPT_PATH },
makeEnv("Write", '{"content":"fix at line 42"}'),
)
expect(result.action).toBe("pass")
expect(result.message).toContain("Evidence of prior research detected")

Check failure on line 66 in packages/opencode/test/hook/factcheck.test.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

error: Received value must be an array type

at <anonymous> (/home/runner/work/opencode/opencode/packages/opencode/test/hook/factcheck.test.ts:66:28)
})

test("passes write with evidence phrase 'grep result'", async () => {
const result = await runHook(
{ command: SCRIPT_PATH },
makeEnv("write", '{"content":"grep result shows usage"}'),
)
expect(result.action).toBe("pass")
expect(result.message).toContain("Evidence of prior research detected")
})

test("passes Edit with evidence phrase 'read tool output'", async () => {
const result = await runHook(
{ command: SCRIPT_PATH },
makeEnv("Edit", '{"old_string":"per read tool output"}'),
)
expect(result.action).toBe("pass")
expect(result.message).toContain("Evidence of prior research detected")

Check failure on line 84 in packages/opencode/test/hook/factcheck.test.ts

View workflow job for this annotation

GitHub Actions / unit (linux)

error: Received value must be an array type

at <anonymous> (/home/runner/work/opencode/opencode/packages/opencode/test/hook/factcheck.test.ts:84:28)
})

test("never blocks (advisory only)", async () => {
const result = await runHook(
{ command: SCRIPT_PATH },
makeEnv("Write", '{"file_path":"/tmp/x","content":"no evidence here"}'),
)
expect(result.action).toBe("pass")
})
})
Loading