From harness-engineering
Explains how the PostToolUse Write/Edit lint feedback loop works end-to-end: hook routing, Rust-based linter invocation, additionalContext injection, and how to extend it with a new language. Use when debugging why a hook isn't firing, when adding support for a new language, or when understanding why Claude 'knows' about a lint violation without being told. Do NOT use for consumer-facing setup (use harness-setup).
npx claudepluginhub toru-oizumi/claude-harness-engineering --plugin harness-engineeringThis skill uses the workspace's default tool permissions.
The mechanical heart of the harness. Explains what happens between "Claude writes a file" and "Claude sees the lint violation on the next turn".
Generates design tokens/docs from CSS/Tailwind/styled-components codebases, audits visual consistency across 10 dimensions, detects AI slop in UI.
Records polished WebM UI demo videos of web apps using Playwright with cursor overlay, natural pacing, and three-phase scripting. Activates for demo, walkthrough, screen recording, or tutorial requests.
Delivers idiomatic Kotlin patterns for null safety, immutability, sealed classes, coroutines, Flows, extensions, DSL builders, and Gradle DSL. Use when writing, reviewing, refactoring, or designing Kotlin code.
The mechanical heart of the harness. Explains what happens between "Claude writes a file" and "Claude sees the lint violation on the next turn".
1. Claude calls: Write({ file_path: "src/user.ts", ... })
│
2. Claude Code triggers PreToolUse hook
│
3. pre-tool-use.sh reads stdin once, fans out to:
├─ pre-config-protect.js (exits 2 if biome.json/tsconfig/etc.)
└─ pre-bash-firewall.js (only relevant for Bash tool)
│
4. If no block → Claude Code executes Write → file is written to disk
│
5. Claude Code triggers PostToolUse hook with the same payload
│
6. post-tool-use.sh reads stdin once, fans out to:
└─ post-edit-lint.js
│
7. post-edit-lint.js:
├─ Routes by file extension:
│ ├─ .ts/.tsx → runTypeScript()
│ ├─ .go → runGo()
│ └─ .proto → runProto()
├─ Each runner:
│ ├─ Silent auto-fix (biome format, oxlint --fix, gofumpt -w, buf format -w)
│ ├─ Collect remaining violations (oxlint, golangci-lint, buf lint, buf breaking)
│ └─ Return { file, tool, output } if violations remain
└─ Aggregate → createHookOutput(message)
│
8. Hook writes JSON to stdout:
{
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "# harness-engineering: lint/format violations\n..."
}
}
│
9. Claude Code injects additionalContext into the next assistant turn
│
10. Claude reads the violations in its next thinking step and fixes them.
Total latency target: under 3 seconds. The Rust-based tools (Biome, Oxlint, gofumpt, buf) are all sub-second. golangci-lint --fast mode is ~200–500ms on a single file. The bottleneck is Node.js startup for the hook itself (~80ms).
PreToolUse sees the intended write but the file on disk is still the old version. Running a linter at PreToolUse would either:
PostToolUse runs after the write is committed to disk, so the linter sees the actual new content. If violations remain, we feed them back via additionalContext. No blocking — just feedback. Claude naturally fixes the issues on its next turn.
Claude Code's PostToolUse hooks can emit JSON with hookSpecificOutput.additionalContext. This string is automatically included in Claude's context for the next assistant turn, like a system-level nudge.
Alternative: write to stderr and hope Claude sees it. This works but is less reliable — stderr sometimes goes to logs, sometimes to the transcript, depending on the Claude Code version. additionalContext is the documented, stable mechanism.
Suppose you want to add Python support. Steps:
hooks/post-edit-lint.jsfunction runPython(fp, log) {
// 1. Auto-format with Ruff
if (commandExists('ruff')) {
runCommand('ruff', ['format', fp], { timeout: 3000 });
}
// 2. Auto-fix with Ruff
if (commandExists('ruff')) {
runCommand('ruff', ['check', '--fix', fp], { timeout: 3000 });
}
// 3. Collect remaining violations
const res = runCommand('ruff', ['check', fp], { timeout: 5000 });
if (res.status === 0) return null;
return {
file: fp,
tool: 'ruff',
output: (res.stdout + res.stderr).trim().slice(0, 3000),
};
}
case '.py': {
const v = runPython(fp, log);
if (v) violations.push(v);
break;
}
hook-utils.js// PROTECTED_CONFIG_BASENAMES
'pyproject.toml',
'ruff.toml',
'.ruff.toml',
skills/python-stack/SKILL.mdModel it on go-stack/SKILL.md.
templates/review-profile.python.yaml_meta/manifest.yamlDone. No changes to the hook runner scripts or config schema needed.
# Check Claude Code logs
tail -f ~/.claude/logs/hook-*.log
# Manually invoke the hook
echo '{"tool_name":"Write","tool_input":{"file_path":"src/test.ts"}}' \
| bash ~/.claude/plugins/harness-engineering/hooks/post-tool-use.sh
node_modules, dist, vendor)?biome, oxlint, gofumpt, buf)?time: time (echo '...' | bash hooks/post-tool-use.sh)golangci-lint run without --fast mode. Always use --fast in the hook.tsc --noEmit on the whole project. Never do that in PostToolUse. Leave tsc for lefthook pre-commit.|| true or a try/catch. A broken hook should never block Claude.gosec, knip, full tsc, buf breaking against remote → all belong in lefthook pre-commit or CI.config-protect — the other half of the harnessts-stack, go-stack, proto-stack — per-language specificsSee gotchas.md.