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
21 changes: 19 additions & 2 deletions .agents/hooks.json
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
{
"discipline": {
"PreToolUse": [
{
"hooks": [
{
"command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent agy --event pre-tool --observe",
"timeout": 30,
"type": "command"
}
],
"matcher": ".*"
}
],
"SessionStart": [
{
"command": "command -v discipline >/dev/null 2>&1 && { echo '{}'; exit 0; }; echo '{\"injectSteps\":[{\"ephemeralMessage\":\"discipline is not installed or not on PATH, so the discipline hook in this repository cannot check your changes. Tell the user before you finish, so they can install it (https://orieg.github.io/discipline/).\"}]}'",
"timeout": 10,
"type": "command"
},
{
"command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent agy --event session-start",
"timeout": 30,
"type": "command"
}
],
"Stop": [
{
"command": "discipline hook run --agent agy --observe",
"timeout": 120,
"command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; echo '{}'; exit 0; }; discipline hook run --agent agy --observe",
"timeout": 300,
"type": "command"
}
]
Expand Down
5 changes: 4 additions & 1 deletion .claude/hooks/discipline-bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@
# A Claude Code cloud session starts on a fresh VM without discipline. This installs
# the pinned release, checksum-verified, so the hooks in .claude/settings.json can
# check the change. Locally it does nothing: install discipline yourself.
# Claude Code cloud sessions run only on repositories hosted on GitHub; elsewhere this
# script never runs in the cloud.
set -u
[ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0
command -v discipline >/dev/null 2>&1 && exit 0

version="v0.14.4"
version="v0.18.0"
say() { echo "discipline bootstrap: $*" >&2; }
case "$(uname -m)" in
x86_64|amd64) arch="x86_64" ;;
Expand All @@ -17,6 +19,7 @@ esac
asset="discipline-${arch}-unknown-linux-musl.tar.gz"
base="https://github.com/orieg/discipline/releases/download/${version}"
dir="$(mktemp -d)"
trap 'rm -rf "${dir}"' EXIT
if ! curl -fsSL --retry 3 -o "${dir}/${asset}" "${base}/${asset}" \
|| ! curl -fsSL --retry 3 -o "${dir}/SHA256SUMS" "${base}/SHA256SUMS"; then
say "could not download ${version} (network access level?); the hooks cannot check this session"
Expand Down
19 changes: 17 additions & 2 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,34 @@
{
"hooks": [
{
"command": "discipline hook run --agent claude-code --observe",
"command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent claude-code --observe",
"type": "command"
}
],
"matcher": "Edit|Write|MultiEdit|NotebookEdit"
}
],
"PreToolUse": [
{
"hooks": [
{
"command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent claude-code --event pre-tool --observe",
"type": "command"
}
],
"matcher": "Edit|Write|MultiEdit|NotebookEdit|Bash"
}
],
"SessionStart": [
{
"hooks": [
{
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/discipline-bootstrap.sh",
"type": "command"
},
{
"command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent claude-code --event session-start",
"type": "command"
}
],
"matcher": "startup|resume"
Expand All @@ -26,7 +41,7 @@
{
"hooks": [
{
"command": "discipline hook run --agent claude-code --observe",
"command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent claude-code --observe",
"type": "command"
}
]
Expand Down
19 changes: 17 additions & 2 deletions .github/hooks/discipline.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,33 @@
"hooks": {
"agentStop": [
{
"bash": "discipline hook run --agent copilot --observe",
"bash": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent copilot --observe",
"timeoutSec": 120,
"type": "command"
}
],
"postToolUse": [
{
"bash": "discipline hook run --agent copilot --observe",
"bash": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent copilot --observe",
"matcher": "create|edit|str_replace_editor|apply_patch",
"timeoutSec": 120,
"type": "command"
}
],
"preToolUse": [
{
"bash": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent copilot --event pre-tool --observe",
"matcher": "create|edit|str_replace_editor|apply_patch|bash",
"timeoutSec": 30,
"type": "command"
}
],
"sessionStart": [
{
"bash": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent copilot --event session-start",
"timeoutSec": 30,
"type": "command"
}
]
},
"version": 1
Expand Down
80 changes: 80 additions & 0 deletions .opencode/plugins/discipline.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// Written by `discipline hook install --agent opencode`.
// When a session is created, takes this worktree's lease for it. Before an edit tool, refuses an edit outside this session's worktree (the tool call
// is sent on stdin; a refusal throws, and the model reads the reason). After it, runs
// the discipline check and, when it fails, appends the report to the tool's output so
// the model reads it and repairs the change.
// Observe mode: no hook below refuses a tool call or throws, whatever the command does (a non-zero exit, a command that is not found, any other error).
import { $ } from "bun"

const EDIT_TOOLS = ["edit", "write", "apply_patch"]

export const Discipline = async ({ $, directory }) => ({
// A new session takes this worktree's lease; it never blocks the session.
event: async ({ event }) => {
try {
if (event.type !== "session.created") return
const start = new Response(JSON.stringify({ input: { sessionID: event.properties?.sessionID }, cwd: event.properties?.info?.directory ?? directory }))
await $`discipline hook run --agent opencode --event session-start < ${start}`.nothrow().quiet()
} catch {}
},
"tool.execute.before": async (input, output) => {
try {
if (!EDIT_TOOLS.includes(input.tool) && input.tool !== "bash") return
const call = new Response(JSON.stringify({ input, output, cwd: directory }))
await $`discipline hook run --agent opencode --event pre-tool --observe < ${call}`.cwd(directory).nothrow().quiet()
} catch {}
},
"tool.execute.after": async (input, output) => {
try {
if (!EDIT_TOOLS.includes(input.tool)) return
const r = await $`discipline hook run --agent opencode --observe`.cwd(directory).nothrow().quiet()
if (r.exitCode !== 0) {
output.output += "\n\n" + r.stdout.toString() + r.stderr.toString()
}
} catch {}
},
})

export default {
id: "discipline",
setup({ tool, event, location }) {
const directory = location?.directory ?? process.cwd()

event?.subscribe?.(async (e) => {
try {
if (e?.type !== "session.created") return
const start = new Response(JSON.stringify({
input: { sessionID: e.properties?.sessionID },
cwd: e.properties?.info?.directory ?? directory
}))
await $`discipline hook run --agent opencode --event session-start < ${start}`.nothrow().quiet()
} catch {}
})

tool?.hook?.("execute.before", async (call) => {
try {
const toolName = call.tool ?? call.input?.tool
if (!EDIT_TOOLS.includes(toolName) && toolName !== "bash") return
const payload = new Response(JSON.stringify({
input: { tool: toolName, sessionID: call.sessionID },
output: { args: call.input ?? {} },
cwd: directory
}))
await $`discipline hook run --agent opencode --event pre-tool --observe < ${payload}`.cwd(directory).nothrow().quiet()
} catch {}
})

tool?.hook?.("execute.after", async (call) => {
try {
const toolName = call.tool ?? call.input?.tool
if (!EDIT_TOOLS.includes(toolName)) return
const r = await $`discipline hook run --agent opencode --observe`.cwd(directory).nothrow().quiet()
if (r.exitCode !== 0) {
if (call.result) {
call.result.output = (call.result.output ?? "") + "\n\n" + r.stdout.toString() + r.stderr.toString()
}
}
} catch {}
})
}
}
Loading