Repository navigation
Expand file tree
/
Copy pathcommand-hooks.jsonc
More file actions
185 lines (181 loc) · 11.4 KB
/
Copy pathcommand-hooks.jsonc
File metadata and controls
185 lines (181 loc) · 11.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
{
// Command hooks — declarative shell commands on tool and session events.
//
// Global defaults for every project. A project adds or overrides hooks in
// <project>/.opencode/command-hooks.jsonc; a hook with the same id replaces
// the one here, and `overrideGlobal: true` suppresses every hook below for
// the same phase+tool.
//
// Full reference: skills/command-hooks/SKILL.md
// Documentation: .opencode/context/research/opencode-command-hooks/
//
// ── Why these defaults exist ──────────────────────────────────────────────
//
// Every rule in this configuration that says "verify your own work" relies on
// each agent remembering to do it. That is the same class of problem as
// asking an orchestrator to call a validator: non-deterministic, and it costs
// tokens in the reminder that asks for it. A hook runs on the event, every
// time, without being asked.
//
// `injectOn: "failure"` is the load-bearing option. A passing typecheck
// reports nothing — it costs no tokens — so the hook is silent exactly when
// things are fine and speaks only when the agent needs to know.
// Per-stream capture cap before truncation. Matches OpenCode's bash tool.
"truncationLimit": 30000,
// Cap on the rendered message that actually reaches the model. The character
// limit above bounds each stream; this bounds the whole injection, so a
// chatty command cannot flood the next turn's context.
"injectLimit": 4000,
"tool": [
{
// Type errors after any subagent returns. The single highest-value
// check: a subagent reports success far more often than it reports a
// broken import, and the parent cannot see the difference.
//
// `npx tsc --noEmit` rather than a package script so this works in a
// project that has no `typecheck` script, which is most of them.
"id": "typecheck-after-task",
"when": { "phase": "after", "tool": "task" },
"run": "npx --no-install tsc --noEmit -p tsconfig.json 2>&1 || npx --no-install tsc --noEmit 2>&1",
"injectOn": "failure",
"inject": "<typecheck tool=\"{tool}\" exit=\"{exitCode}\">\nType errors after the subagent completed. Fix these before reporting success:\n\n```\n{stdout}{stderr}\n```\n</typecheck>",
"toast": {
"title": "typecheck",
"message": "failed (exit {exitCode})",
"variant": "error",
"duration": 6000
}
},
{
// A subagent that wrote a file should have produced a test run. This
// injects only on failure, so a green suite stays silent and a red
// one is explained before the parent claims the work is done.
//
// Skipped when the project has no test script at all — `|| true`
// would make a missing suite look like a passing one, so the `test -f`
// gate keeps "no tests configured" out of the reported results.
"id": "tests-after-task",
"when": { "phase": "after", "tool": "task" },
"run": "if [ -f package.json ] && node -e \"const s=require('./package.json').scripts||{};process.exit(s.test?0:1)\" 2>/dev/null; then npm test --silent 2>&1; else exit 0; fi",
"injectOn": "failure",
"inject": "<tests tool=\"{tool}\" exit=\"{exitCode}\">\nThe test suite failed after the subagent completed. Do not report success until this passes:\n\n```\n{stdout}{stderr}\n```\n</tests>",
"toast": {
"title": "tests",
"message": "failed (exit {exitCode})",
"variant": "error",
"duration": 6000
}
},
{
// The graph and the vector store are rebuilt by a sync hook, but the
// graph invariants themselves are not asserted anywhere at runtime.
// After an edit that touches a source file, run the cheap integrity
// check so an inconsistent graph is reported where it was created.
"id": "graph-integrity-after-write",
"when": {
"phase": "after",
"tool": ["write", "edit", "patch", "regex-edit", "json-edit", "yaml-edit", "conf-edit", "multi-edit"],
"toolArgs": {
"filePath": { "glob": "**/skills/graph-context/scripts/*.ts" }
}
},
"run": "npx --no-install tsc --noEmit -p skills/tsconfig.json 2>&1 | head -40",
"injectOn": "failure",
"inject": "<graph-integrity exit=\"{exitCode}\">\nA file under skills/graph-context/scripts no longer typechecks. The graph invariants in graph-context/SKILL.md are documented as holding; that is now in doubt:\n\n```\n{stdout}{stderr}\n```\n</graph-integrity>"
},
{
// Rebuild the TUI hub menu bundle when a hub spec or a hub SKILL.md
// changes. This is the mechanical half of rules/hub-menu-rebuild.md.
//
// The rule existed because the step was manual and got skipped: the
// dialog rendered from a generated bundle, so a committed spec with a
// stale bundle made a working subcommand invisible with no error
// anywhere. It shipped that way twice (79280b0 — graph, self-improve,
// delegate and insights all missing; 5ae6d27).
//
// Silent on success (`injectOn: "failure"`) and debounced by the hook
// runner's own dedupe, because regenerating on every keystroke-level
// edit would cost more than the staleness it prevents. `dist/tui.js`
// is gitignored and rebuilt locally; `src/generated-hubs.ts` is
// committed, so the pair is what actually has to stay in step.
"id": "hub-menu-rebuild-after-write",
"when": {
"phase": "after",
"tool": ["write", "edit", "patch", "regex-edit", "json-edit", "yaml-edit", "conf-edit", "multi-edit"],
"toolArgs": {
// One glob, not an array: the matcher takes a single pattern
// (`{glob: string}`) and an array here invalidates the whole
// file — every hook below silently stops firing. picomatch
// expands the braces, so all three sources fit in one string.
"filePath": {
"glob": "{tools/hubs/**/*.ts,tools/hub-*.ts,skills/*/SKILL.md}"
}
}
},
"run": "cd plugins/hubs-tui && bun run generate-menus >/dev/null 2>&1 && bun build src/tui.tsx --outdir dist --target bun --minify >/dev/null 2>&1",
"injectOn": "failure",
"inject": "<hub-menu-rebuild exit=\"{exitCode}\">\nThe hub menu bundle failed to regenerate after a spec change. Until this succeeds, /maintain-hub and its siblings render from a stale bundle and any new or renamed subcommand is INVISIBLE in the dialog. Run: cd plugins/hubs-tui && bun run generate-menus && bun build src/tui.tsx --outdir dist --target bun --minify\n\n```\n{stdout}{stderr}\n```\n</hub-menu-rebuild>",
"toast": {
"title": "hub menu bundle",
"message": "regeneration failed (exit {exitCode})",
"variant": "error",
"duration": 8000
}
}
],
"session": [
{
// A hook on session start is the cheapest place to surface state that
// every turn would otherwise re-derive: whether the previous session
// left an active mode, and whether the test suite is currently green.
//
// This replaces the per-turn efficiency reminder doing the same job by
// hand, and does it once instead of on every cycle.
"id": "session-start-context",
"when": { "event": "session.start" },
"run": "printf 'repo: %s\\nbranch: %s\\n' \"$(basename \"$PWD\")\" \"$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo none)\"",
"injectOn": "always",
"inject": "<session-context>\n{stdout}\n</session-context>"
},
{
// The mechanical half of rules/auto-commit.md.
//
// One commit per completed turn, not one per file edit. The hook fires
// when the root session goes idle — the point at which the agent has
// stopped working — so a turn that edits ten files produces a single
// commit instead of ten. A turn that changed nothing exits at the
// `git diff --cached --quiet` check and costs nothing.
//
// The message is mechanical (file count plus the first four paths), so
// it describes the diff without an inference call. It is not a summary
// of the work; the diff is.
//
// It refuses to commit a change that looks like a secret, which is the
// one case the rule yields on: a false skip costs a review, a false
// commit leaks a credential into history permanently. Silent on success
// (`injectOn: "failure"`), so a normal commit spends nothing.
//
// Submodule pointer (gitlink) changes are excluded. A submodule is its
// own repository, managed deliberately in its own right; a drifted
// gitlink is not a consequence of editing a file, and folding one into
// an auto-commit silently advances the revision the superproject
// records. Each submodule path becomes a `:(exclude)` pathspec so
// `git add` stages everything else. Repos without submodules are
// unaffected (the exclude list is empty).
//
// `rootSessionOnly` defaults to true for session.idle, so a subagent's
// session finishing mid-fan-out does not commit a half-done tree.
"id": "auto-commit-on-idle",
"when": { "event": "session.idle" },
"run": "if git rev-parse --git-dir >/dev/null 2>&1; then excludes=$(git submodule foreach --quiet 'printf \":(exclude)%s\\n\" \"$sm_path\"' 2>/dev/null); git add -A -- $excludes 2>/dev/null; if git diff --cached --quiet; then exit 0; fi; if git diff --cached | grep -qiE 'BEGIN [A-Z ]*PRIVATE KEY|sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}'; then echo 'secret-like content staged; auto-commit skipped'; exit 1; fi; names=$(git diff --cached --name-only); count=$(printf '%s\\n' \"$names\" | grep -c .); shown=$(printf '%s\\n' \"$names\" | head -4 | tr '\\n' ' '); msg=\"auto: ${count} file(s): ${shown}\"; if [ \"$count\" -gt 4 ]; then msg=\"${msg}+$((count-4)) more\"; fi; git commit -q -m \"$msg\"; fi",
"injectOn": "failure",
"inject": "<auto-commit exit=\"{exitCode}\">\nThe mechanical auto-commit (rules/auto-commit.md) did not complete. A staged change looked like it contained a secret, so the commit was skipped rather than committed. Review `git diff --cached`: unstage the secret, or commit deliberately if it is a false positive.\n\n```\n{stdout}{stderr}\n```\n</auto-commit>",
"toast": {
"title": "auto-commit",
"message": "skipped (exit {exitCode})",
"variant": "error",
"duration": 8000
}
}
]
}