diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index bef44cd3b..31dfe8fae 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -70,6 +70,19 @@
}
}
},
+ {
+ "name": "go-format",
+ "source": "./plugins/go-format",
+ "category": "development",
+ "tags": ["go", "golang", "goimports", "formatter", "hook"],
+ "relevance": {
+ "topic": "Go",
+ "signals": {
+ "filesRead": ["**/*.go"],
+ "cli": ["goimports"]
+ }
+ }
+ },
{
"name": "eol-normalizer",
"displayName": "EOL Normalizer",
diff --git a/README.md b/README.md
index c4ae90865..1287a1fcd 100644
--- a/README.md
+++ b/README.md
@@ -74,12 +74,13 @@ user opts in with `/plugin enable`; an existing install is never flipped by cata
- [`biome-format`](plugins/biome-format) — Auto-format and lint JS/TS/JSX/JSON on edit via Biome, only when a biome.json governs the repo — using the consuming repo's own Biome config.
- [`ruff-format`](plugins/ruff-format) — Auto-format and lint Python on edit via Ruff, only when a Ruff config governs the repo — using the consuming repo's own Ruff config.
- [`typos-format`](plugins/typos-format) — Auto-fix spelling typos on edit via typos-cli, unconditionally — honoring the consuming repo's own typos configuration when one is present.
+- [`go-format`](plugins/go-format) — Auto-fix Go formatting and import management on edit via goimports — runs unconditionally (no consumer-config gate), skipping generated files.
- [`eol-normalizer`](plugins/eol-normalizer) — Normalize a written file's working-tree line endings to its .gitattributes eol value on edit — symmetric CRLF/LF driven by git check-attr, advisory and never blocking.
- [`powershell-format`](plugins/powershell-format) — Auto-format and lint PowerShell on edit via PSScriptAnalyzer, only when a PSScriptAnalyzerSettings.psd1 governs the repo — using the consuming repo's own analyzer settings.
- [`actionlint`](plugins/actionlint) — Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.
- [`source-control`](plugins/source-control) — Git and GitHub delivery workflow: /commit (Conventional Commits + Co-Authored-By trailer via safe heredoc mechanics), /pull-request (prep, create, CI monitoring, review-comment triage, merge, CI-log fetch), /babysit-prs (self-pacing fleet loop — safe by default; opt-in worker/autopilot tiers add gate-checked merge and thread resolution behind a deterministic Python engine), /worktree (create, status, cleanup, audit for parallel-session isolation), /setup (check the effective commit-subject / PR-title convention merged across its config layers and the babysit-prs config, or apply — interview the repo and write the convention config to a chosen layer), and /resolve-conflicts (intent-first merge/rebase conflict resolution with a semantic-conflict sweep — never --abort). The commit-subject / PR-title convention is configurable via a source-control.md config written by a re-runnable setup skill, layered across a ~/.claude user-global file, the tracked team file, and a gitignored .claude/source-control.local.md personal overlay merged per key; Conventional Commits is the default when no convention is declared.
- [`implementation`](plugins/implementation) — Disciplined implementation stage: execute approved plans inline (`/implementation:implement`) or via orchestrated worker subagents (`/implementation:implement-dispatch`) with incremental validation, TDD-by-default cadence, green-checkpoint commits, scope-fence drift detection, and divergence detection that routes back to planning. Build/test/lint, testing, and outcome verification live in the companion `toolchain`, `testing`, and `verification` plugins, invoked when installed.
-- [`toolchain`](plugins/toolchain) — Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).
+- [`toolchain`](plugins/toolchain) — Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, Go, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).
### Testing
diff --git a/docs/conventions/ecosystem-commands/examples/go.yaml b/docs/conventions/ecosystem-commands/examples/go.yaml
new file mode 100644
index 000000000..4af9c0287
--- /dev/null
+++ b/docs/conventions/ecosystem-commands/examples/go.yaml
@@ -0,0 +1,16 @@
+# Example .claude/ecosystems/go.yaml — a consuming repo's Go command surface.
+# Contract: docs/conventions/ecosystem-commands/README.md (schema: ecosystem.schema.json).
+globs: ["*.go", "go.mod", "go.sum"]
+project-discovery: ["go.mod"]
+build-cmd: "go build ./..."
+test-cmd: "go test ./..."
+check-cmd: "golangci-lint run ./..."
+fix-cmd: "golangci-lint run --fix ./..."
+opt-in: ".golangci.yml, .golangci.yaml, .golangci.toml, or .golangci.json present (walked from the changed file up to the repo root) — otherwise golangci-lint applies its own unconfigured \"standard\" linter preset unconditionally"
+install-hint: "Install golangci-lint: https://golangci-lint.run/docs/welcome/install/ | Go toolchain: https://go.dev/dl/"
+gates:
+ - name: go-mod-tidy-drift
+ cmd: "go mod tidy -diff"
+ trigger-globs: ["go.mod", "go.sum", "*.go"]
+ remediation: "Run go mod tidy and commit the updated go.mod/go.sum. (go mod tidy -diff requires Go 1.23+; on an older toolchain the gate errors on the unrecognized flag rather than reporting drift.)"
+notes: "govulncheck is intentionally not a rung-4 default (per the epic brief's \"optional\" framing) — add it as a consumer-local gate via .claude/ecosystems/go.local.yaml if desired."
diff --git a/docs/conventions/hook-telemetry/README.md b/docs/conventions/hook-telemetry/README.md
index 01af5009d..c75950724 100644
--- a/docs/conventions/hook-telemetry/README.md
+++ b/docs/conventions/hook-telemetry/README.md
@@ -155,6 +155,7 @@ producers without coordinating with them or each other.
| `markdown-format` plugin | `markdown-format` | `data/markdown-format.schema.json` |
| `typos-format` plugin | `typos-format` | `data/typos-format.schema.json` |
| `ruff-format` plugin | `ruff-format` | `data/ruff-format.schema.json` |
+| `go-format` plugin | `go-format` | `data/go-format.schema.json` |
| `bash-format` plugin | `bash-format` | `data/bash-format.schema.json` |
| `desktop-notification` plugin | `desktop-notification` | `data/desktop-notification.schema.json` |
| `guardrails` plugin | `secret-pattern-detection` | `data/secret-pattern-detection.schema.json` |
diff --git a/docs/conventions/hook-telemetry/data/go-format.schema.json b/docs/conventions/hook-telemetry/data/go-format.schema.json
new file mode 100644
index 000000000..850e88a17
--- /dev/null
+++ b/docs/conventions/hook-telemetry/data/go-format.schema.json
@@ -0,0 +1,24 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/hook-telemetry/data/go-format.schema.json",
+ "title": "go-format telemetry data",
+ "description": "Per-hook `data` payload for the go-format hook. Discovered from the envelope `hook` value \"go-format\". Evolves additive-only.",
+ "type": "object",
+ "required": ["tool", "file", "findings"],
+ "additionalProperties": true,
+ "properties": {
+ "tool": {
+ "type": "string",
+ "description": "Claude Code tool that triggered the hook (Write or Edit)."
+ },
+ "file": {
+ "type": "string",
+ "description": "Path of the formatted Go file, relative to the consuming repo root."
+ },
+ "findings": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "goimports syntax-error diagnostic lines when the file could not be parsed, one line per diagnostic. Empty array = clean or successfully autofixed (a successful format/import fix carries no findings)."
+ }
+ }
+}
diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json
new file mode 100644
index 000000000..3e090668e
--- /dev/null
+++ b/plugins/go-format/.claude-plugin/plugin.json
@@ -0,0 +1,26 @@
+{
+ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
+ "name": "go-format",
+ "version": "0.1.0",
+ "description": "Auto-fix Go formatting and import management on edit via goimports — runs unconditionally (no consumer-config gate), skipping generated files.",
+ "author": {
+ "name": "Melodic Software",
+ "email": "info@melodicsoftware.com"
+ },
+ "license": "MIT",
+ "keywords": [
+ "go",
+ "golang",
+ "goimports",
+ "formatter",
+ "hook"
+ ],
+ "userConfig": {
+ "go_format_enabled": {
+ "type": "boolean",
+ "title": "go-format hook",
+ "description": "Run goimports -w on edit of a Go file",
+ "default": true
+ }
+ }
+}
diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md
new file mode 100644
index 000000000..cdb3dde63
--- /dev/null
+++ b/plugins/go-format/CHANGELOG.md
@@ -0,0 +1,21 @@
+# Changelog
+
+All notable changes to the `go-format` plugin are documented here. Format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+
+## [0.1.0]
+
+### Added
+
+- Initial release: a `PostToolUse` hook that runs `goimports -w` on
+ `Write`/`Edit` of a `.go` file — unconditionally, with no consumer-config
+ opt-in gate (the one deliberate shape difference from the
+ `ruff-format`/`typos-format` pattern; see issue #832's field survey).
+ Skips files carrying Go's `// Code generated ... DO NOT EDIT.` marker.
+ Syntax errors goimports can't parse surface via `additionalContext` as an
+ advisory finding, never a tool break. Advisory only — never blocks the
+ edit.
+- `hook-telemetry` conformance: emits a schema-valid envelope
+ (`docs/conventions/hook-telemetry/data/go-format.schema.json`) via the
+ shared `hook::emit_telemetry` helper.
+- `/go-format:setup check|apply` skill for prerequisite verification.
diff --git a/plugins/go-format/README.md b/plugins/go-format/README.md
new file mode 100644
index 000000000..15c84d3f6
--- /dev/null
+++ b/plugins/go-format/README.md
@@ -0,0 +1,101 @@
+# go-format
+
+A Claude Code plugin that formats Go files and manages their imports the
+moment you edit them. On every `Write` or `Edit` of a `.go` file it runs
+[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports)'s `-w`,
+which adds missing imports, removes unused ones, and applies `gofmt`-
+equivalent formatting — then surfaces any syntax error goimports can't parse
+back to Claude as advisory context.
+
+## Behavior
+
+- **Unconditional — no consumer-config opt-in gate.** Unlike sibling
+ formatter plugins (`ruff-format`, `typos-format`), this hook runs on every
+ edited `.go` file regardless of repository configuration. `goimports`'
+ own docs describe it as "a replacement for your editor's gofmt-on-save
+ hook" and it has no meaningful config-divergence axis when left
+ unconfigured — running it does not impose a style choice a repo hasn't
+ made, the same reasoning that makes `gofmt` itself safe to run
+ unconditionally.
+- **Extension-scoped.** Only `.go` files trigger the hook (like
+ `ruff-format`'s `*.py`/`*.pyi` filter; unlike `typos-format`'s
+ language-agnostic scope).
+- **Skips generated files.** A file whose leading comment/blank-line block
+ contains Go's canonical `// Code generated ... DO NOT EDIT.` marker is
+ left untouched — this includes files where a copyright/license header
+ (a `//` or `/* */` block) precedes the marker, common for
+ `addlicense`/`goheader` output. `goimports` itself has no awareness of
+ that convention, so this hook adds the guard itself.
+- **Fix in place.** Formatting and import changes are applied silently — no
+ advisory noise on a successful fix, the same posture as a successful
+ `ruff-format`/`typos-format` autofix pass.
+- **Groups local imports using your module's own path.** When a `go`
+ toolchain is on `PATH`, the hook resolves the edited file's own module
+ path (`go list -m`) and passes it as goimports' `-local` grouping prefix,
+ so your package's own internal imports stay in their own group instead of
+ being collapsed into the third-party group — matching goimports' own
+ `-local` convention without adding any new consumer config. Falls back to
+ goimports' plain default grouping when `go` is absent or the file isn't
+ in a resolvable module.
+- **Syntax errors surface as advisory findings.** When `goimports` can't
+ parse the file, the parse diagnostic is reported via `additionalContext`,
+ never auto-"fixed" and never treated as a tool break.
+- **Advisory, never blocking.** The hook always exits `0`. Findings are
+ reported via `additionalContext`; they never reject the edit. Make a
+ commit hook or CI your hard gate.
+
+## Requirements
+
+- **Bash** — the hook is a Bash script. On native Windows, install
+ [Git for Windows](https://code.claude.com/docs/en/setup#set-up-on-windows) so
+ Claude Code can run it under Git Bash.
+- **jq** on `PATH` — parses the hook payload. Absent: the hook skips with a
+ visible once-per-session notice. [Install jq](https://jqlang.org/download/).
+- **goimports** on `PATH`. Like `typos-format`, `goimports` has no
+ per-repo dependency-manager convention — it is conventionally
+ `go install`ed to the machine-global `$GOPATH/bin`. It is never
+ downloaded on the fly; if it is not present, the hook skips with a
+ visible once-per-session notice.
+ [Install](https://pkg.go.dev/golang.org/x/tools/cmd/goimports):
+ `go install golang.org/x/tools/cmd/goimports@latest` (requires a
+ [Go toolchain](https://go.dev/dl/)).
+- **`go` on `PATH` (optional).** Used only to resolve the `-local` grouping
+ prefix (`go list -m`). Absent: the hook still formats/fixes imports, just
+ without the `-local` grouping (goimports' plain default behavior).
+
+The hook itself runs on Bash 3.2+. Telemetry timing uses `EPOCHREALTIME`
+(Bash 5.0+); on older bash the telemetry envelope is skipped while
+formatting still runs.
+
+## Install
+
+```shell
+/plugin marketplace add melodic-software/claude-code-plugins
+/plugin install go-format@melodic-software
+```
+
+Then verify prerequisites with `/go-format:setup check`.
+
+## Configuration
+
+There are no rules to configure — `goimports` runs with no consumer-config
+surface to read. One `userConfig` option tunes the hook itself:
+
+| Option | Default | Effect |
+|--------|---------|--------|
+| `go_format_enabled` | `true` | Kill switch — set `false` for a clean no-op. |
+
+Set it interactively with `/plugin configure go-format`, or headless on the
+install command:
+
+```shell
+claude plugin install go-format@melodic-software --config go_format_enabled=false
+```
+
+These options are user-scoped (stored in your user settings, not the
+project's). To turn the plugin off for a single repository, disable it in
+that project's `enabledPlugins` instead.
+
+## License
+
+MIT (SPDX-License-Identifier: MIT).
diff --git a/plugins/go-format/hooks/go-format.sh b/plugins/go-format/hooks/go-format.sh
new file mode 100755
index 000000000..3251bc84c
--- /dev/null
+++ b/plugins/go-format/hooks/go-format.sh
@@ -0,0 +1,260 @@
+#!/usr/bin/env bash
+# PostToolUse hook: auto-format and manage imports for Go files via goimports.
+# Triggered on Write|Edit of *.go files.
+#
+# ADVISORY: always exits 0. `goimports -w` rewrites the file in place;
+# a residual syntax error goimports cannot parse surfaces via
+# additionalContext but never blocks the edit. A commit hook or CI is the
+# hard gate.
+#
+# UNCONDITIONAL — no consumer-config opt-in gate, unlike the sibling
+# ruff-format/typos-format/dotnet ecosystem entry. goimports' own docs state
+# it "formats your code in the same style as gofmt so it can be used as a
+# replacement for your editor's gofmt-on-save hook" — an explicit official
+# statement of intent for exactly this per-file/on-save scenario, and it has
+# no meaningful config-divergence axis when left unconfigured (unlike
+# ruff/dotnet format, whose underlying tools DO have configurable, genuinely
+# divergent output).
+#
+# GENERATED-FILE GUARD: goimports has zero awareness of Go's own
+# `// Code generated ... DO NOT EDIT.` convention — empirically confirmed it
+# rewrites such files with no warning. Generated Go files (protobuf, mockgen,
+# sqlc, stringer, wire output) are common; this hook skips any file whose
+# leading comment/blank-line block (scanned below) contains that marker,
+# mirroring the precision `--force-exclude` gives Ruff/typos for free via
+# consumer config (this hook has no config to consult, so the marker check
+# is the equivalent guard).
+#
+# The goimports binary is resolved from PATH only — never downloaded. Go
+# binaries are conventionally `go install`ed to $GOPATH/bin, which a
+# developer adds to PATH themselves; there is no per-project virtualenv
+# concept in Go the way ruff-format walks a .venv.
+
+set -uo pipefail
+
+# Read inherited fd0 directly (bare cat) — NEVER ` silent no-op). stdin is read ONCE here and fed to both
+# hook::read_file_path (file_path) and the tool_name parse below; reading fd0
+# twice would drain the pipe on the second call.
+# shellcheck source=hook-utils.sh
+source "$(dirname "${BASH_SOURCE[0]}")/hook-utils.sh"
+
+hook::check_enabled "GO_FORMAT"
+
+# Capture $EPOCHREALTIME immediately after kill-switch so duration_ms covers the
+# work below (pre-work exits do not emit telemetry). EPOCHREALTIME is Bash 5.0+;
+# on older bash it is unset, so default to empty — referencing it bare under
+# `set -u` would abort before the advisory exit 0, failing every edit.
+start=${EPOCHREALTIME:-}
+
+# Telemetry needs the high-res start stamp. When EPOCHREALTIME is unavailable
+# (Bash < 5.0) the stamp is empty and telemetry is skipped, so the hook still
+# formats on older bash rather than aborting.
+emit_tel() {
+ [[ -n "$start" ]] || return 0
+ hook::emit_telemetry "$@"
+}
+
+INPUT=$(hook::buffer_stdin) || exit 0
+
+# jq-free applicability pre-filter: never emit the jq notice for an edit this
+# hook would not process anyway (the Write|Edit matcher is broader than the
+# Go-file filter).
+RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0
+case "$RAW_FILE" in
+*.go) ;;
+*) exit 0 ;;
+esac
+
+# jq is load-bearing for input parsing; absent → visible once-per-session skip
+# notice instead of a silent no-op (dim-9 doctrine).
+hook::require_jq PostToolUse go-format "$INPUT"
+
+FILE=$(printf '%s' "$INPUT" | hook::read_file_path) || exit 0
+case "$FILE" in
+*.go) ;;
+*) exit 0 ;;
+esac
+
+TOOL=$(printf '%s' "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null)
+
+# Resolve repo root early — used to compute the schema-required repo-relative
+# path in data.file.
+REPO_ROOT="$(hook::repo_root "$(dirname "$FILE")")"
+# Repo-relative path: schema requires "relative to the consuming repo root".
+# On Windows Git Bash, git rev-parse --show-toplevel returns a drive-letter path
+# while FILE may be in POSIX mount form. Normalize both through cygpath -lm
+# (long name, forward-slash mixed form) when available so the prefix strip
+# compares the same representation. On Linux/macOS, cygpath is absent and both
+# paths are already POSIX. Falls back to raw FILE on any normalization error.
+FILE_REL="$FILE"
+if command -v cygpath >/dev/null 2>&1; then
+ _file_lm=$(cygpath -lm "$FILE" 2>/dev/null)
+ _root_lm=$(cygpath -lm "$REPO_ROOT" 2>/dev/null)
+ if [[ -n "$_file_lm" && -n "$_root_lm" ]]; then
+ FILE_REL="${_file_lm#"$_root_lm"/}"
+ fi
+else
+ FILE_REL="${FILE#"$REPO_ROOT"/}"
+fi
+
+# Build the telemetry data object for the current TOOL/FILE_REL. $1 is the
+# findings JSON array. jq is authoritative. The fallback is a fixed empty-shape
+# object — NOT an interpolation of TOOL/FILE_REL, which could inject quotes or
+# backslashes from a path and corrupt the envelope.
+build_data_json() {
+ jq -n \
+ --arg tool "$TOOL" \
+ --arg file "$FILE_REL" \
+ --argjson findings "$1" \
+ '{tool:$tool,file:$file,findings:$findings}' 2>/dev/null ||
+ printf '{"tool":"","file":"","findings":[]}'
+}
+
+emit_skipped() {
+ local data_json
+ data_json=$(build_data_json '[]')
+ emit_tel "go-format" "PostToolUse" "skipped" "$start" "$data_json" "$REPO_ROOT"
+ exit 0
+}
+
+# Generated-file guard: skip files carrying Go's canonical generated-code
+# marker — goimports itself has no awareness of this convention (empirically
+# confirmed it rewrites such files silently). Go's own convention (`go help
+# generate`, verified live: "This line must appear before the first
+# non-comment, non-blank text in the file") does not restrict "comment" to
+# `//` style — a `/* ... */` block comment (e.g. a conventional block
+# license header) is a comment for this purpose too, so it must not end the
+# leading-block scan early. Scan the file's leading comment/blank-line run,
+# tracking open `/* */` blocks, and stop only at the first line that is
+# genuinely neither blank, a `//` comment, nor inside/starting a `/* */`
+# block (e.g. `package foo`). A trailing CRLF `\r` and a leading UTF-8 BOM
+# are stripped per line so a Windows-checked-out or BOM-prefixed file still
+# matches. (The marker itself can only ever appear on a `//`-prefixed line —
+# `^// Code generated .* DO NOT EDIT\.$` — never inside a `/* */` block, so
+# block-comment lines are only ever scanned-through, not matched against.)
+GENERATED=0
+IN_BLOCK=0
+while IFS= read -r _line || [[ -n "$_line" ]]; do
+ _line="${_line%$'\r'}"
+ _line="${_line#$'\xEF\xBB\xBF'}"
+ if [[ $IN_BLOCK -eq 1 ]]; then
+ [[ "$_line" == *'*/'* ]] && IN_BLOCK=0
+ continue # still inside (or just closed) a block comment: keep scanning
+ fi
+ [[ -n "${_line//[[:space:]]/}" ]] || continue # blank line (any whitespace, incl. tabs): keep scanning the leading block
+ # Trimmed only for comment-shape classification (an indented `//`/`/*`
+ # still counts as "still within the leading comment block") — the marker
+ # regex itself stays column-0-anchored, matching Go's own convention.
+ _trimmed="${_line#"${_line%%[![:space:]]*}"}"
+ if [[ "$_line" == //* ]]; then
+ if [[ "$_line" =~ ^//\ Code\ generated\ .*\ DO\ NOT\ EDIT\.$ ]]; then
+ GENERATED=1
+ fi
+ [[ $GENERATED -eq 1 ]] && break
+ continue # a different // comment line: still within the leading block
+ fi
+ if [[ "$_trimmed" == //* ]]; then
+ continue # an indented // comment line: still within the leading block
+ fi
+ if [[ "$_trimmed" == /\** ]]; then
+ [[ "$_trimmed" == *'*/'* ]] || IN_BLOCK=1 # opens a block comment spanning further lines
+ continue # an indented /* ... */ comment (single- or multi-line): still within the leading block
+ fi
+ break # first non-comment, non-blank line: leading block ended, marker absent
+done <"$FILE"
+[[ $GENERATED -eq 1 ]] && emit_skipped
+
+# Resolve the goimports binary from PATH — never downloaded.
+GOIMPORTS_BIN="$(command -v goimports 2>/dev/null)" || GOIMPORTS_BIN=""
+
+if [[ -z "$GOIMPORTS_BIN" ]]; then
+ if hook::notice_once "go-format-goimports" "$INPUT"; then
+ hook::emit_skip_notice PostToolUse "go-format: no 'goimports' binary found on PATH — format/import-fix skipped for this session. Install: go install golang.org/x/tools/cmd/goimports@latest"
+ fi
+ emit_skipped
+fi
+
+# Auto-derive goimports' -local grouping prefix from the edited file's own
+# module path (`go list -m`, which walks up to the nearest go.mod using
+# Go's own module resolution — more robust than hand-parsing the module
+# directive). Without -local, goimports lumps a repo's own internal
+# packages into the same group as third-party imports; a repo that already
+# formats with -local (a common Go convention, e.g. wired into its own CI
+# or editor config) would have this hook re-collapse that grouping on every
+# edit — empirically confirmed this materially changes output when a
+# third-party import is also present. Deriving the LOCAL prefix from the
+# file's own module path (self-grouping) requires no new consumer-config
+# surface, so it stays within the unconditional/no-opt-in design while
+# covering the single most common -local use case. `go` absent, the file
+# outside any module, or any other resolution failure all degrade to no
+# -local flag (goimports' plain default grouping), never a hard stop.
+LOCAL_PREFIX=""
+if command -v go >/dev/null 2>&1; then
+ LOCAL_PREFIX="$(cd "$(dirname "$FILE")" 2>/dev/null && go list -m 2>/dev/null)" || LOCAL_PREFIX=""
+ [[ "$LOCAL_PREFIX" == "command-line-arguments" ]] && LOCAL_PREFIX=""
+fi
+GOIMPORTS_ARGS=(-w -l)
+[[ -n "$LOCAL_PREFIX" ]] && GOIMPORTS_ARGS+=(-local "$LOCAL_PREFIX")
+
+# -w writes the fix in place; -l (combined with -w) lists the changed
+# filename on stdout, which this hook doesn't need (a successful autofix
+# carries no advisory noise, same posture as a successful ruff/typos fix
+# pass — only a genuine syntax error below produces a finding). Verified
+# empirically (goimports v0.48.0): -l ALWAYS exits 0, even when it lists a
+# file — there is no exit-1-style "findings" signal like ruff/typos have.
+# Non-zero exit (verified: 2, with a parseable message on stderr) occurs
+# only on a genuine parse/syntax error — captured via command substitution
+# (stdout discarded, stderr redirected to fd1) the same way every sibling
+# hook in this repo captures tool output, rather than a temp file. `--`
+# ends flag parsing before $FILE — defense-in-depth against a path that
+# happens to start with `-` being misread as a flag by Go's flag package.
+STDERR=$("$GOIMPORTS_BIN" "${GOIMPORTS_ARGS[@]}" -- "$FILE" 2>&1 >/dev/null)
+RC=$?
+
+if [[ $RC -eq 0 ]]; then
+ # Clean, or fixed silently (formatting/import changes carry no advisory
+ # noise — same posture as a successful ruff/typos autofix pass).
+ data_json=$(build_data_json '[]')
+ emit_tel "go-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT"
+ exit 0
+fi
+
+if [[ $RC -eq 2 && -n "$STDERR" ]]; then
+ # goimports ran and produced a judgment: the file has a syntax error it
+ # cannot parse. This is a finding, not a tool break — mirrors how
+ # ruff-format surfaces a mid-edit syntax error as a finding.
+ hook::ctx_reset
+ hook::ctx_append "go-format: $(basename "$FILE") has a syntax error goimports could not parse (advisory):"
+ findings_raw=""
+ while IFS= read -r line; do
+ [[ -n "$line" ]] || continue
+ hook::ctx_append " $line"
+ findings_raw+="$line"$'\n'
+ done <<<"$STDERR"
+ hook::ctx_flush PostToolUse
+
+ FINDINGS_JSON='[]'
+ if [[ -n "$findings_raw" ]]; then
+ FINDINGS_JSON=$(printf '%s' "$findings_raw" | jq -R . | jq -s . 2>/dev/null) || FINDINGS_JSON='[]'
+ fi
+ data_json=$(build_data_json "$FINDINGS_JSON")
+ emit_tel "go-format" "PostToolUse" "ok" "$start" "$data_json" "$REPO_ROOT"
+ exit 0
+fi
+
+# goimports broke for non-syntax reasons (internal error, unexpected exit
+# code) — no judgment was made. Surface the diagnostic via additionalContext
+# (NOT stderr — an advisory hook's exit-0 stderr can trip a false "Hook
+# Error" label). Record as "skipped" (the tool never ran to judgment).
+hook::ctx_reset
+hook::ctx_append "go-format: goimports failed for $(basename "$FILE") (no diagnostics; tool break, not a finding):"
+while IFS= read -r line; do
+ [[ -n "$line" ]] || continue
+ hook::ctx_append " $line"
+done <<<"$STDERR"
+hook::ctx_flush PostToolUse
+data_json=$(build_data_json '[]')
+emit_tel "go-format" "PostToolUse" "skipped" "$start" "$data_json" "$REPO_ROOT"
+exit 0
diff --git a/plugins/go-format/hooks/go-format.test.sh b/plugins/go-format/hooks/go-format.test.sh
new file mode 100755
index 000000000..dce6a824b
--- /dev/null
+++ b/plugins/go-format/hooks/go-format.test.sh
@@ -0,0 +1,428 @@
+#!/usr/bin/env bash
+# Black-box contract test for go-format.sh (the go-format plugin hook).
+#
+# Proves WIRING: the hook fires only on *.go files (extension pre-filter),
+# runs goimports UNCONDITIONALLY (no consumer-config opt-in gate — the one
+# deliberate shape difference from ruff-format/typos-format; see
+# docs/topics/832-go-ecosystem/PLAN.md Open Decision 1), skips files carrying
+# Go's generated-code marker, autofixes imports/formatting in place, surfaces
+# a syntax error as an advisory finding (not a tool break), honors the kill
+# switch, and emits a schema-valid telemetry envelope.
+#
+# Self-contained: builds throwaway git repos with runtime-generated fixtures.
+# The hook is invoked from an UNRELATED cwd so any reliance on the caller's
+# own working directory would surface.
+#
+# Requires a real goimports binary: $GOIMPORTS_TEST_BIN if set, else
+# `goimports` on PATH. Without one the behavioral assertions cannot run, so
+# the suite skips.
+
+set -uo pipefail
+
+HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+HOOK="$HOOK_DIR/go-format.sh"
+
+PASS=0
+FAIL=0
+fail() {
+ echo "FAIL: $*" >&2
+ FAIL=$((FAIL + 1))
+}
+ok() {
+ echo "ok: $*"
+ PASS=$((PASS + 1))
+}
+
+# Resolve a real goimports binary. Skip the suite when none is available.
+if [[ -n "${GOIMPORTS_TEST_BIN:-}" && -x "${GOIMPORTS_TEST_BIN}" ]]; then
+ REAL_GOIMPORTS="${GOIMPORTS_TEST_BIN}"
+elif command -v goimports >/dev/null 2>&1; then
+ REAL_GOIMPORTS="$(command -v goimports)"
+else
+ echo "SKIP: no goimports binary (set GOIMPORTS_TEST_BIN or put goimports on PATH) -- go-format hook tests skipped"
+ exit 0
+fi
+
+WORK="$(mktemp -d)"
+UNRELATED="$(mktemp -d)"
+cleanup() { rm -rf "$WORK" "$UNRELATED"; }
+trap cleanup EXIT
+
+# make_sink
-> path to an executable single-command stub sink running
+# (which reads the envelope on stdin). HOOK_TELEMETRY_SINK must be a
+# single executable path, not a command-with-args, so tests point it at a stub.
+make_sink() {
+ local s
+ s="$(mktemp -p "$WORK" sink.XXXXXX)"
+ {
+ printf '#!/usr/bin/env bash\n'
+ printf '%s\n' "$1"
+ } >"$s"
+ chmod +x "$s"
+ printf '%s' "$s"
+}
+
+# wait_for_sink [tries] -> block until is non-empty (the
+# fire-and-forget sink flushed) or the bound elapses, polling in 20ms steps.
+wait_for_sink() {
+ local f="$1" tries="${2:-150}"
+ while ((tries-- > 0)); do
+ if [[ -s "$f" ]]; then
+ return 0
+ fi
+ sleep 0.02
+ done
+ return 1
+}
+
+new_go_repo() {
+ local r="$1"
+ mkdir -p "$r"
+ git -C "$r" init -q
+ git -C "$r" config user.email t@t.t
+ git -C "$r" config user.name t
+}
+
+# Invoke the hook from an unrelated cwd. CLAUDE_PROJECT_DIR is left UNSET so
+# read_file_path's membership guard is disabled (not part of the fire gate).
+run_hook() {
+ local file_path="$1"
+ (
+ cd "$UNRELATED" || return 1
+ printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$file_path" |
+ env -u CLAUDE_PROJECT_DIR CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" bash "$HOOK"
+ )
+}
+
+# Same as run_hook but with caller-supplied extra env (NAME=VALUE ...).
+run_hook_env() {
+ local file_path="$1"
+ shift
+ (
+ cd "$UNRELATED" || return 1
+ printf '{"tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$file_path" |
+ env -u CLAUDE_PROJECT_DIR "$@" bash "$HOOK"
+ )
+}
+
+REPO="$WORK/consumer"
+new_go_repo "$REPO"
+
+# --- Case 1: unconditional (no config anywhere) -> still runs ---------------
+# Unlike ruff-format/typos-format, go-format has NO consumer-config opt-in
+# gate — it must autofix even with zero Go-specific config present.
+printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/needs_import.go"
+OUT=$(run_hook "$REPO/needs_import.go")
+RC=$?
+if [[ $RC -eq 0 ]]; then ok "no config anywhere -> exit 0 (unconditional)"; else fail "no-config exit $RC"; fi
+if grep -q 'import "fmt"' "$REPO/needs_import.go"; then
+ ok "no config anywhere -> import still added (unconditional, no opt-in gate)"
+else
+ fail "no-config -> import not added: $(cat "$REPO/needs_import.go")"
+fi
+
+# --- Case 2: clean file -> exit 0, empty stdout ------------------------------
+printf 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/clean.go"
+OUT=$(run_hook "$REPO/clean.go")
+RC=$?
+if [[ $RC -eq 0 ]]; then ok "clean file -> exit 0"; else fail "clean file exit $RC"; fi
+if [[ -z "$OUT" ]]; then ok "clean file -> empty stdout"; else fail "clean file stdout not empty: $OUT"; fi
+
+# --- Case 3: import needed by edit -> auto-added in a subdir -----------------
+mkdir -p "$REPO/src"
+printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/src/fix.go"
+OUT=$(run_hook "$REPO/src/fix.go")
+RC=$?
+if [[ $RC -eq 0 ]]; then ok "missing import (subdir) -> exit 0 (advisory)"; else fail "missing import exit $RC"; fi
+if grep -q 'import "fmt"' "$REPO/src/fix.go"; then
+ ok "missing import (subdir) -> auto-added"
+else
+ fail "missing import -> not added: $(cat "$REPO/src/fix.go")"
+fi
+
+# --- Case 4: unused import -> auto-removed -----------------------------------
+printf 'package main\n\nimport (\n\t"fmt"\n\t"os"\n)\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/unused.go"
+run_hook "$REPO/unused.go" >/dev/null
+if ! grep -q '"os"' "$REPO/unused.go"; then
+ ok "unused import -> auto-removed"
+else
+ fail "unused import -> still present: $(cat "$REPO/unused.go")"
+fi
+
+# --- Case 4b: -local grouping auto-derived from the file's own go.mod -------
+# Without -local, goimports lumps local (in-module) imports into the same
+# group as third-party imports. The hook should derive -local from the
+# edited file's own module path (`go list -m`) so a repo's own internal
+# packages get their own group, matching goimports' documented -local
+# behavior — requires a real `go` binary on PATH (skip this case if absent,
+# same posture as the goimports-binary requirement for the whole suite).
+if command -v go >/dev/null 2>&1; then
+ REPO_LOCAL="$WORK/local-grouping"
+ new_go_repo "$REPO_LOCAL"
+ printf 'module example.com/localtest\n\ngo 1.23\n' >"$REPO_LOCAL/go.mod"
+ mkdir -p "$REPO_LOCAL/internal/pkg"
+ printf 'package pkg\n\nfunc Hello() string { return "hi" }\n' >"$REPO_LOCAL/internal/pkg/pkg.go"
+ # A third-party-shaped (unresolved, never fetched) import is required to
+ # observe -local's effect: with only stdlib + local imports present,
+ # goimports' output is IDENTICAL with or without -local (empirically
+ # confirmed) — -local's job is separating LOCAL from THIRD-PARTY, and
+ # goimports can reorder an already-present import without resolving it.
+ printf 'package main\n\nimport (\n\t"example.com/localtest/internal/pkg"\n\t"fmt"\n\t"github.com/pkg/errors"\n)\n\nfunc main() {\n\tfmt.Println(pkg.Hello())\n\t_ = errors.New("x")\n}\n' >"$REPO_LOCAL/main.go"
+ run_hook "$REPO_LOCAL/main.go" >/dev/null
+ # With -local applied, the local import gets its own trailing group,
+ # separated from the third-party group by a blank line (goimports'
+ # un-grouped default would instead lump local + third-party into one
+ # sorted block after stdlib).
+ if grep -qF $'"github.com/pkg/errors"\n\n\t"example.com/localtest/internal/pkg"' "$REPO_LOCAL/main.go"; then
+ ok "-local auto-derived from go.mod -> local import grouped separately from third-party"
+ else
+ fail "-local grouping not applied: $(cat "$REPO_LOCAL/main.go")"
+ fi
+else
+ echo "SKIP: no go binary on PATH -- -local grouping case (4b) skipped"
+fi
+
+# --- Case 5: generated-file marker -> skipped, left untouched ---------------
+printf '// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated.go"
+BEFORE_GEN="$(cat "$REPO/generated.go")"
+OUT=$(run_hook "$REPO/generated.go")
+RC=$?
+if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "generated-marker file -> exit 0, silent"; else fail "generated-marker not silent (rc=$RC out=$OUT)"; fi
+if [[ "$(cat "$REPO/generated.go")" == "$BEFORE_GEN" ]]; then
+ ok "generated-marker file -> left untouched (import NOT added despite being missing)"
+else
+ fail "generated-marker file -> was rewritten: $(cat "$REPO/generated.go")"
+fi
+
+# --- Case 5b: marker preceded by a license-header comment block -------------
+# Common real-world shape (addlicense/goheader-style tooling prepends a
+# copyright header before the generated-code marker) — the marker is NOT on
+# the file's first non-blank line. The guard must still catch it.
+printf '// Copyright 2026 Example Corp. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license that can be found in the LICENSE file.\n\n// Code generated by "stringer -type Op"; DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-header.go"
+BEFORE_GH="$(cat "$REPO/generated-header.go")"
+run_hook "$REPO/generated-header.go" >/dev/null
+if [[ "$(cat "$REPO/generated-header.go")" == "$BEFORE_GH" ]]; then
+ ok "generated-marker behind a license-header block -> still caught, left untouched"
+else
+ fail "generated-marker behind a license-header block -> was rewritten: $(cat "$REPO/generated-header.go")"
+fi
+
+# --- Case 5c: marker line has a trailing CRLF \r -----------------------------
+printf '// Code generated by protoc-gen-go. DO NOT EDIT.\r\npackage main\r\n\r\nfunc main() {\r\n\tfmt.Println("hi")\r\n}\r\n' >"$REPO/generated-crlf.go"
+BEFORE_CRLF="$(cat "$REPO/generated-crlf.go")"
+run_hook "$REPO/generated-crlf.go" >/dev/null
+if [[ "$(cat "$REPO/generated-crlf.go")" == "$BEFORE_CRLF" ]]; then
+ ok "generated-marker with CRLF line endings -> still caught, left untouched"
+else
+ fail "generated-marker with CRLF line endings -> was rewritten"
+fi
+
+# --- Case 5d: UTF-8 BOM before the marker on the first line -----------------
+printf '\xEF\xBB\xBF// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-bom.go"
+BEFORE_BOM="$(cat "$REPO/generated-bom.go")"
+run_hook "$REPO/generated-bom.go" >/dev/null
+if [[ "$(cat "$REPO/generated-bom.go")" == "$BEFORE_BOM" ]]; then
+ ok "generated-marker with a leading UTF-8 BOM -> still caught, left untouched"
+else
+ fail "generated-marker with a leading UTF-8 BOM -> was rewritten"
+fi
+
+# --- Case 5e: marker AFTER the leading comment/blank block -> NOT generated -
+# The marker must appear before the first non-comment, non-blank line to
+# count — a file that merely mentions the marker text after `package main`
+# has already started is real (or at least not-provably-generated) code and
+# must still be formatted normally.
+printf 'package main\n\n// Code generated by protoc-gen-go. DO NOT EDIT.\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/not-generated.go"
+run_hook "$REPO/not-generated.go" >/dev/null
+if grep -q 'import "fmt"' "$REPO/not-generated.go"; then
+ ok "marker after leading block -> not treated as generated, import still added"
+else
+ fail "marker after leading block -> wrongly treated as generated: $(cat "$REPO/not-generated.go")"
+fi
+
+# --- Case 5f: marker preceded by a /* */ block-comment license header -------
+# Go's own convention ("go help generate": the marker "must appear before
+# the first non-comment, non-blank text in the file") does not restrict
+# "comment" to `//` style — a block-comment license header must not defeat
+# the guard either.
+printf '/*\nCopyright 2026 Example Corp. All rights reserved.\n*/\n\n// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-block-header.go"
+BEFORE_BLOCK="$(cat "$REPO/generated-block-header.go")"
+run_hook "$REPO/generated-block-header.go" >/dev/null
+if [[ "$(cat "$REPO/generated-block-header.go")" == "$BEFORE_BLOCK" ]]; then
+ ok "generated-marker behind a /* */ block-comment header -> still caught, left untouched"
+else
+ fail "generated-marker behind a /* */ block-comment header -> was rewritten: $(cat "$REPO/generated-block-header.go")"
+fi
+
+# --- Case 5g: INDENTED /* */ block-comment header before the marker ---------
+# go/ast.IsGenerated classifies a file as generated regardless of
+# block-comment indentation; comment-shape classification must tolerate
+# leading whitespace even though the marker regex itself stays
+# column-0-anchored (matching Go's own convention exactly).
+printf ' /*\n Copyright 2026 Example Corp. All rights reserved.\n */\n\n// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-indented-block-header.go"
+BEFORE_INDENT_BLOCK="$(cat "$REPO/generated-indented-block-header.go")"
+run_hook "$REPO/generated-indented-block-header.go" >/dev/null
+if [[ "$(cat "$REPO/generated-indented-block-header.go")" == "$BEFORE_INDENT_BLOCK" ]]; then
+ ok "generated-marker behind an INDENTED /* */ block-comment header -> still caught, left untouched"
+else
+ fail "generated-marker behind an indented /* */ block-comment header -> was rewritten: $(cat "$REPO/generated-indented-block-header.go")"
+fi
+
+# --- Case 5h: TAB-only separator line before the marker ---------------------
+# The blank-line check must treat any horizontal whitespace as blank, not
+# just literal spaces — a tab-only line between a block-comment header and
+# the marker must not be misread as non-comment content.
+printf '/*\nCopyright.\n*/\n\t\n// Code generated by protoc-gen-go. DO NOT EDIT.\npackage main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/generated-tab-blank.go"
+BEFORE_TAB_BLANK="$(cat "$REPO/generated-tab-blank.go")"
+run_hook "$REPO/generated-tab-blank.go" >/dev/null
+if [[ "$(cat "$REPO/generated-tab-blank.go")" == "$BEFORE_TAB_BLANK" ]]; then
+ ok "generated-marker with a TAB-only separator line -> still caught, left untouched"
+else
+ fail "generated-marker with a tab-only separator line -> was rewritten: $(cat "$REPO/generated-tab-blank.go")"
+fi
+
+# --- Case 6: non-.go extension -> hook does not fire -------------------------
+printf 'this is not go' >"$REPO/notes.txt"
+BEFORE_TXT="$(cat "$REPO/notes.txt")"
+OUT=$(run_hook "$REPO/notes.txt")
+RC=$?
+if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "non-.go file -> exit 0, silent"; else fail "non-.go file not silent (rc=$RC out=$OUT)"; fi
+if [[ "$(cat "$REPO/notes.txt")" == "$BEFORE_TXT" ]]; then ok "non-.go file -> untouched"; else fail "non-.go file -> was modified"; fi
+
+# --- Case 7: syntax error -> surfaced as advisory finding, not a tool break --
+printf 'package main\n\nfunc main() {\n\tfmt.Println("hi"\n}\n' >"$REPO/syntax.go"
+OUT=$(run_hook "$REPO/syntax.go")
+RC=$?
+if [[ $RC -eq 0 ]]; then ok "syntax error -> exit 0 (advisory)"; else fail "syntax error exit $RC (must be advisory)"; fi
+if printf '%s' "$OUT" | jq -e '.hookSpecificOutput.additionalContext' >/dev/null 2>&1; then
+ CTX=$(printf '%s' "$OUT" | jq -r '.hookSpecificOutput.additionalContext')
+ if printf '%s' "$CTX" | grep -qi 'syntax error'; then
+ ok "syntax error -> surfaced in additionalContext as a finding"
+ else
+ fail "syntax error ctx wrong shape: $CTX"
+ fi
+else
+ fail "syntax error -> no additionalContext JSON: $OUT"
+fi
+
+# --- Case 8: kill switch bypasses hook ---------------------------------------
+printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/kill.go"
+BEFORE_K="$(cat "$REPO/kill.go")"
+OUT=$(run_hook_env "$REPO/kill.go" PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=false)
+RC=$?
+if [[ $RC -eq 0 && -z "$OUT" ]]; then ok "kill switch off -> exit 0 silent"; else fail "kill switch failed (rc=$RC out=$OUT)"; fi
+if [[ "$(cat "$REPO/kill.go")" == "$BEFORE_K" ]]; then ok "kill switch -> file untouched"; else fail "kill switch -> file was modified"; fi
+
+# ============================================================================
+# Telemetry
+# ============================================================================
+
+# --- Sink unset -> empty stdout, exit 0 (parity) ------------------------------
+printf 'package main\n\nimport "fmt"\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/tel-clean.go"
+OUT_NS=$(run_hook_env "$REPO/tel-clean.go" -u HOOK_TELEMETRY_SINK PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true)
+RC_NS=$?
+if [[ $RC_NS -eq 0 && -z "$OUT_NS" ]]; then
+ ok "telemetry/sink-unset: exit 0, empty stdout (parity)"
+else
+ fail "telemetry/sink-unset: rc=$RC_NS out=$OUT_NS"
+fi
+
+# --- Stub sink + syntax-error finding -> envelope status ok with findings ---
+printf 'package main\n\nfunc main() {\n\tfmt.Println("hi"\n}\n' >"$REPO/tel.go"
+TEL="$(mktemp)"
+SINK="$(make_sink "cat >\"$TEL\"")"
+run_hook_env "$REPO/tel.go" PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true HOOK_TELEMETRY_SINK="$SINK" >/dev/null
+wait_for_sink "$TEL"
+if [[ -s "$TEL" ]]; then
+ ok "telemetry/stub-sink: envelope received"
+ for field in schema_version timestamp hook hook_event status duration_ms data; do
+ if jq -e "has(\"$field\")" "$TEL" >/dev/null 2>&1; then
+ ok "envelope: $field present"
+ else
+ fail "envelope: $field missing ($(cat "$TEL"))"
+ fi
+ done
+ if [[ "$(jq -r '.hook' "$TEL")" == "go-format" ]]; then ok "envelope: hook is go-format"; else fail "envelope: hook=$(jq -r '.hook' "$TEL")"; fi
+ if [[ "$(jq -r '.status' "$TEL")" == "ok" ]]; then ok "envelope: status ok"; else fail "envelope: status=$(jq -r '.status' "$TEL")"; fi
+ if [[ "$(jq -r '.schema_version' "$TEL")" == "1.0" ]]; then ok "envelope: schema_version 1.0"; else fail "envelope: schema_version=$(jq -r '.schema_version' "$TEL")"; fi
+ if [[ "$(jq '.data.findings | length' "$TEL")" -ge 1 ]]; then ok "envelope: findings populated"; else fail "envelope: findings empty ($(jq '.data.findings' "$TEL"))"; fi
+ if jq -e '.data.findings[0] | type == "string"' "$TEL" >/dev/null 2>&1; then ok "envelope: findings are flat strings"; else fail "envelope: findings[0] wrong type ($(jq '.data.findings[0]' "$TEL"))"; fi
+ FREL=$(jq -r '.data.file' "$TEL")
+ if [[ -n "$FREL" && "$FREL" != /* && "$FREL" != ?:* ]]; then ok "envelope: data.file repo-relative ($FREL)"; else fail "envelope: data.file not repo-relative: $FREL"; fi
+ if jq -e '.duration_ms | type == "number" and . >= 0 and floor == .' "$TEL" >/dev/null 2>&1; then ok "envelope: duration_ms non-negative int"; else fail "envelope: duration_ms invalid ($(jq .duration_ms "$TEL"))"; fi
+ if ! printf '%s' "$OUT" | grep -q schema_version 2>/dev/null; then ok "envelope: never leaked into hook's own stdout"; else fail "envelope leaked into stdout"; fi
+else
+ fail "telemetry/stub-sink: no envelope written"
+fi
+rm -f "$TEL"
+
+# --- Stub sink + kill switch -> status skipped -------------------------------
+printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO/tel2.go"
+TELS="$(mktemp)"
+SINKS="$(make_sink "cat >\"$TELS\"")"
+run_hook_env "$REPO/tel2.go" PATH="$(dirname "$REAL_GOIMPORTS"):$PATH" CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=false HOOK_TELEMETRY_SINK="$SINKS" >/dev/null
+if [[ -s "$TELS" ]]; then
+ fail "telemetry/kill-switch: envelope written despite kill switch (should exit before telemetry)"
+else
+ ok "telemetry/kill-switch: no envelope written (hook exits before telemetry setup)"
+fi
+rm -f "$TELS"
+
+# --- Missing-tool visibility (dim-9 doctrine) --------------------------------
+# Fake-bin dir of exec wrappers (no goimports): a *.go edit must produce a
+# visible once-per-session skip notice on both channels, silent on the
+# second run. jq removal then exercises the input-parsing gate.
+FAKEBIN="$(mktemp -d -p "$WORK" fakebin.XXXXXX)"
+for t in bash jq git dirname basename cat env printf mktemp mkdir find tr awk grep sed uname sleep cygpath realpath readlink rm; do
+ real_t="$(command -v "$t" 2>/dev/null)" || continue
+ [[ -n "$real_t" ]] || continue
+ printf '#!/bin/sh\nexec "%s" "$@"\n' "$real_t" >"$FAKEBIN/$t"
+ chmod +x "$FAKEBIN/$t"
+done
+REPO_NG="$WORK/no-goimports"
+mkdir -p "$REPO_NG"
+git -C "$REPO_NG" init -q
+printf 'package main\n\nfunc main() {\n\tfmt.Println("hi")\n}\n' >"$REPO_NG/app.go"
+NG_DATA="$(mktemp -d -p "$WORK" plugdata.XXXXXX)"
+run_ng() {
+ (
+ cd "$UNRELATED" || return 1
+ printf '{"session_id":"test-nogoimports-1","tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO_NG/app.go" |
+ env -u CLAUDE_PROJECT_DIR PATH="$FAKEBIN" CLAUDE_PLUGIN_DATA="$NG_DATA" \
+ CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true bash "$HOOK"
+ )
+}
+OUT_NG=$(run_ng)
+RC_NG=$?
+if [[ $RC_NG -eq 0 ]]; then ok "goimports-absent -> exit 0"; else fail "goimports-absent exit $RC_NG"; fi
+if jq -e '(.systemMessage | contains("goimports")) and (.hookSpecificOutput.additionalContext | contains("goimports"))' <<<"$OUT_NG" >/dev/null 2>&1; then
+ ok "goimports-absent -> visible notice on both channels"
+else
+ fail "goimports-absent: notice missing or malformed: $OUT_NG"
+fi
+OUT_NG2=$(run_ng)
+if [[ -z "$OUT_NG2" ]]; then
+ ok "goimports-absent -> second run same session is silent (once-per-session)"
+else
+ fail "goimports-absent second run not silent: $OUT_NG2"
+fi
+
+# jq-absent -> visible once-per-session notice (input parsing gate).
+rm -f "$FAKEBIN/jq"
+JQ_DATA="$(mktemp -d -p "$WORK" plugdata.XXXXXX)"
+OUT_NOJQ=$(
+ cd "$UNRELATED" || exit 1
+ printf '{"session_id":"test-nojq-1","tool_input":{"file_path":"%s"},"tool_name":"Write"}' "$REPO_NG/app.go" |
+ env -u CLAUDE_PROJECT_DIR PATH="$FAKEBIN" CLAUDE_PLUGIN_DATA="$JQ_DATA" \
+ CLAUDE_PLUGIN_OPTION_GO_FORMAT_ENABLED=true bash "$HOOK"
+)
+RC_NOJQ=$?
+if [[ $RC_NOJQ -eq 0 && "$OUT_NOJQ" == *'"systemMessage"'* && "$OUT_NOJQ" == *jq* ]]; then
+ ok "jq-absent -> exit 0 with visible notice"
+else
+ fail "jq-absent (rc=$RC_NOJQ out=$OUT_NOJQ)"
+fi
+
+echo
+echo "PASS=$PASS FAIL=$FAIL"
+[[ $FAIL -eq 0 ]]
diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh
new file mode 100644
index 000000000..a5234be7b
--- /dev/null
+++ b/plugins/go-format/hooks/hook-utils.sh
@@ -0,0 +1,1048 @@
+# shellcheck shell=bash
+# Shared hook utility library for this marketplace's hook plugins. Sourced
+# (not executed): kill switch, file_path parsing + path normalization,
+# repo-root resolution, additionalContext accumulator, telemetry envelope.
+#
+# SINGLE SOURCE OF TRUTH: lib/hook-utils.sh at the marketplace repo root. The
+# copies at plugins/*/hooks/hook-utils.sh exist because installed plugins are
+# cache-isolated and must be self-contained — never edit a copy. Edit the
+# source and run scripts/sync-hook-utils.sh; CI rejects drifted copies.
+
+# Guard against double-sourcing.
+[[ -n "${_HOOK_UTILS_LOADED:-}" ]] && return 0
+readonly _HOOK_UTILS_LOADED=1
+
+# Per-hook kill switch via the plugin's _enabled userConfig boolean,
+# read from the hook-process CLAUDE_PLUGIN_OPTION__ENABLED mirror.
+# Exits 0 (allow) if disabled. Place after source, before stdin parsing.
+# hook::check_enabled "MARKDOWN_FORMAT" # checks CLAUDE_PLUGIN_OPTION_MARKDOWN_FORMAT_ENABLED
+hook::check_enabled() {
+ local var_name="CLAUDE_PLUGIN_OPTION_${1}_ENABLED"
+ if [[ "${!var_name:-true}" != "true" ]]; then
+ exit 0
+ fi
+}
+
+# --- Prerequisite visibility --------------------------------------------------
+# Doctrine: a missing runtime prerequisite must surface to BOTH the agent
+# (additionalContext) and the user (systemMessage) — a silently skipped feature
+# is a defect. Everything in this section is jq-FREE by design: the most common
+# missing prerequisite is jq itself.
+
+# JSON-escape a string for embedding in a hand-built JSON document. Escapes
+# backslash, double quote, and the line-structure control bytes by name
+# (\n \r \t); the remaining C0 bytes JSON forbids raw are dropped — notice text
+# never carries meaningful control bytes beyond line structure. Byte-safe under
+# UTF-8: every escaped byte is ASCII, and UTF-8 continuation bytes are >= 0x80.
+hook::json_escape() {
+ local s="$1"
+ s="${s//\\/\\\\}"
+ s="${s//\"/\\\"}"
+ s="${s//$'\n'/\\n}"
+ s="${s//$'\r'/\\r}"
+ s="${s//$'\t'/\\t}"
+ # tr drops the residual C0 bytes; if tr itself is unavailable, fall back to
+ # the escaped string as-is — notice text is hook-authored and does not carry
+ # raw control bytes in practice.
+ local out
+ out=$(printf '%s' "$s" | tr -d '\000-\010\013\014\016-\037' 2>/dev/null) || out="$s"
+ printf '%s' "$out"
+}
+
+# Emit hook JSON carrying an agent-channel context (additionalContext) and/or a
+# user-channel message (systemMessage) as ONE document — CC parses the hook's
+# whole stdout as a single JSON doc, so a run that has both lint findings and a
+# pending skip notice must compose them here rather than print twice. Either
+# channel may be empty; emits nothing when both are.
+# hook::emit_channels PostToolUse "$ctx" "$sysmsg"
+hook::emit_channels() {
+ local event="$1" ctx="$2" sysmsg="$3"
+ [[ -n "$ctx" || -n "$sysmsg" ]] || return 0
+ local out="{"
+ if [[ -n "$ctx" ]]; then
+ out+='"hookSpecificOutput":{"hookEventName":"'"$(hook::json_escape "$event")"'","additionalContext":"'"$(hook::json_escape "$ctx")"'"}'
+ [[ -n "$sysmsg" ]] && out+=","
+ fi
+ [[ -n "$sysmsg" ]] && out+='"systemMessage":"'"$(hook::json_escape "$sysmsg")"'"'
+ out+="}"
+ printf '%s\n' "$out"
+}
+
+# Visible skip notice: the same message on both channels. The caller must exit 0
+# right after unless it composes via hook::emit_channels itself.
+# hook::emit_skip_notice PostToolUse "my-plugin: tool X not found — ..."
+hook::emit_skip_notice() {
+ hook::emit_channels "$1" "$2" "$2"
+}
+
+# systemMessage-only variant for hook events with no additionalContext channel
+# (e.g. Notification).
+hook::emit_system_message() {
+ hook::emit_channels "" "" "$1"
+}
+
+# Once-per-session gate for skip notices. Returns 0 (emit now) the first time a
+# given fires in the current session, 1 afterwards — a missing-tool notice
+# behind a broad matcher (every Write|Edit) must not repeat on every edit. The
+# session id is regex-extracted from the raw hook input JSON (jq-free, see
+# section header); marker files live under ${CLAUDE_PLUGIN_DATA} (survives
+# plugin updates; mkdir -p defensively since creation is documented only on
+# first *reference*) and markers older than 7 days are pruned so per-session
+# files cannot accumulate unboundedly. Fails open toward visibility: when no
+# marker can be tracked, emit every time.
+# hook::notice_once "my-plugin-jq" "$INPUT" && hook::emit_skip_notice ...
+hook::notice_once() {
+ local key="$1" input="${2:-}" session="no-session"
+ if [[ "$input" =~ \"session_id\"[[:space:]]*:[[:space:]]*\"([^\"]+)\" ]]; then
+ session="${BASH_REMATCH[1]}"
+ session="${session//[^A-Za-z0-9_-]/-}"
+ fi
+ local dir="${CLAUDE_PLUGIN_DATA:-}"
+ [[ -n "$dir" ]] || return 0
+ dir="$dir/skip-notices"
+ mkdir -p "$dir" 2>/dev/null || return 0
+ find "$dir" -type f -mtime +7 -delete 2>/dev/null
+ local marker="$dir/${key}.${session}"
+ [[ -f "$marker" ]] && return 1
+ : >"$marker" 2>/dev/null
+ return 0
+}
+
+# Best-effort jq-free extraction of tool_input.file_path from the raw hook
+# input, for the applicability pre-filter an extension-scoped hook runs BEFORE
+# its jq gate — a missing-jq notice must never fire for an edit the hook would
+# not process anyway (e.g. a README edit reaching a workflow-lint hook whose
+# Write|Edit matcher is broader than its file filter). The value is returned
+# JSON-escaped (backslashes doubled); that is fine for extension/segment
+# matching, which is all the pre-filter does. Returns 1 when no file_path is
+# present.
+# RAW_FILE=$(hook::raw_file_path "$INPUT") || exit 0
+hook::raw_file_path() {
+ [[ "$1" =~ \"file_path\"[[:space:]]*:[[:space:]]*\"(([^\"\\]|\\.)*)\" ]] || return 1
+ [[ -n "${BASH_REMATCH[1]}" ]] || return 1
+ printf '%s' "${BASH_REMATCH[1]}"
+}
+
+# jq gate for hooks whose input parsing cannot proceed without it. When jq is
+# absent: one visible skip notice per session, then exit 0 — an advisory hook
+# never blocks the tool over a missing prerequisite. Place after
+# hook::check_enabled (and after any jq-free applicability pre-filter), passing
+# the buffered stdin for session scoping.
+# hook::require_jq PostToolUse my-plugin "$INPUT"
+hook::require_jq() {
+ command -v jq >/dev/null 2>&1 && return 0
+ local event="$1" plugin="$2" input="${3:-}"
+ if hook::notice_once "${plugin}-jq" "$input"; then
+ hook::emit_skip_notice "$event" \
+ "$plugin: jq not found on PATH — hook skipped for this session. Install jq (https://jqlang.org/download/) to enable it."
+ fi
+ exit 0
+}
+
+# Normalize a path for the membership comparison below: backslashes → forward
+# slashes, and — only on Windows/MSYS, whose filesystem is case-insensitive —
+# fold a leading drive (POSIX `/c/...` or `c:/...`) to an upper-case drive
+# letter + lower-cased remainder so the byte-exact comparison is effectively
+# case-insensitive. The fold is gated on the host (OSTYPE), NOT on the path
+# shape: on a case-sensitive POSIX filesystem a real single-letter top-level
+# directory such as `/c/Repo` must pass through unchanged, otherwise it would
+# collapse with `/c/repo` and the membership guard would admit a sibling
+# outside CLAUDE_PROJECT_DIR. The result is used ONLY for comparison; the
+# emitted path is always the caller's original.
+hook::normalize_path() {
+ local p="${1//\\//}"
+ case "${OSTYPE:-}" in
+ msys* | cygwin* | win32)
+ if [[ "$p" =~ ^/([a-zA-Z])/ || "$p" =~ ^([a-zA-Z]):/ ]]; then
+ local rest="${p:2}"
+ printf '%s' "${BASH_REMATCH[1]^}:${rest,,}"
+ return
+ fi
+ ;;
+ *) ;; # POSIX hosts: case-sensitive FS, no drive fold — pass through below
+ esac
+ printf '%s' "$p"
+}
+
+# Canonicalize to a physical path — symlinks resolved — for the membership
+# comparison below, so an in-project symlink pointing outside the project root
+# cannot defeat the guard (the lexical path would pass the prefix check while
+# the write lands elsewhere). GNU realpath ships with Git Bash and Linux
+# coreutils; readlink -f covers the BSD/macOS hosts that have no realpath.
+# When neither resolver exists the caller falls back to comparing the lexical
+# path as before — the guard is defense-in-depth scoping for a file the agent
+# already wrote via its own tools, so degrading to the historical comparison
+# beats silently disabling the hook on those hosts.
+hook::physical_path() {
+ local resolved
+ if resolved=$(realpath -- "$1" 2>/dev/null) || resolved=$(readlink -f -- "$1" 2>/dev/null); then
+ if [[ -n "$resolved" ]]; then
+ printf '%s' "$resolved"
+ return
+ fi
+ fi
+ printf '%s' "$1"
+}
+
+# Parse file_path from PostToolUse JSON on stdin; validate existence and (when
+# CLAUDE_PROJECT_DIR is set) project membership. Both sides of the membership
+# comparison are canonicalized (symlinks resolved) first, so neither an
+# escaping symlink nor a project root reached via a symlinked path (e.g.
+# macOS /tmp) skews the verdict. Outputs the path on success. Returns 1 to skip.
+# FILE=$(hook::read_file_path) || exit 0
+hook::read_file_path() {
+ local file
+ file=$(jq -r '(.tool_input.file_path // empty) | gsub("\r";"")' 2>/dev/null)
+ [[ -n "$file" ]] || return 1
+ [[ -f "$file" ]] || return 1
+ if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]]; then
+ local norm_file norm_project
+ norm_file=$(hook::normalize_path "$(hook::physical_path "$file")")
+ norm_project=$(hook::normalize_path "$(hook::physical_path "${CLAUDE_PROJECT_DIR}")")
+ norm_project="${norm_project%/}"
+ # Anchor on a path-segment boundary: accept the project root itself or a
+ # child under it, but not a sibling whose name merely shares the prefix
+ # (e.g. /c/repo must not admit /c/repo-backup/...).
+ if [[ "$norm_file" != "$norm_project" && "$norm_file" != "$norm_project"/* ]]; then
+ return 1
+ fi
+ fi
+ printf '%s' "$file"
+}
+
+# Resolve the repository root (working-tree top) for a path inside the tree.
+# markdownlint config auto-discovery is CWD-anchored, so the hook cd's here
+# before linting. File-anchored (`git -C "$hint" rev-parse --show-toplevel`)
+# so it is correct for clones, linked worktrees, and bare-hub clones; falls
+# back to the hint (with a trailing /.claude stripped) when git cannot resolve.
+# ROOT=$(hook::repo_root "$some_path")
+hook::repo_root() {
+ local hint="${1:-.}"
+ local root
+ root=$(git -C "$hint" rev-parse --show-toplevel 2>/dev/null | tr -d '\r')
+ if [[ -z "$root" ]]; then
+ root="$hint"
+ root="${root%/.claude}"
+ root="${root%\\.claude}"
+ fi
+ printf '%s' "$root"
+}
+
+# Buffer a complete JSON payload from stdin, tolerating Windows Win32-pipe
+# late-EOF stalls via a bounded read on the inherited fd0. Returns the payload
+# on success; returns 1 on empty/incomplete stdin (caller skips), or 2 when the
+# read timed out before a complete JSON payload arrived (caller may block).
+# Bound is the stdin_read_timeout userConfig option in seconds (read via
+# CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT, default 2). jq (when present)
+# distinguishes a truncated read from a genuinely small-but-complete payload; a
+# missing/broken jq (exit 127) fails open like absent jq.
+# INPUT=$(hook::buffer_stdin) || exit 0
+hook::buffer_stdin() {
+ local input="" read_status=0 read_timeout="${CLAUDE_PLUGIN_OPTION_STDIN_READ_TIMEOUT:-2}" start_epoch elapsed_ms timeout_ms
+ start_epoch=${EPOCHREALTIME:-}
+ IFS= read -r -d '' -t "$read_timeout" input || read_status=$?
+ input=$(printf '%s' "$input" | tr -d '\r')
+ [[ -n "$input" ]] || return 1
+ local jq_rc=0
+ if [[ "$read_status" -ne 0 ]] && command -v jq >/dev/null 2>&1; then
+ jq -e . >/dev/null 2>&1 <<<"$input" || jq_rc=$?
+ fi
+ if [[ "$read_status" -ne 0 && "$jq_rc" -ne 0 && "$jq_rc" -ne 127 ]]; then
+ elapsed_ms=$(awk -v start="$start_epoch" -v end="$EPOCHREALTIME" 'BEGIN { printf "%.0f", (end - start) * 1000 }')
+ timeout_ms=$(awk -v timeout="$read_timeout" 'BEGIN { printf "%.0f", timeout * 1000 }')
+ if [[ "$elapsed_ms" =~ ^[0-9]+$ && "$timeout_ms" =~ ^[0-9]+$ ]] &&
+ ((elapsed_ms + 100 >= timeout_ms)); then
+ echo "BLOCKED: hook stdin timed out before a complete JSON payload arrived." >&2
+ return 2
+ fi
+ return 1
+ fi
+ printf '%s' "$input"
+}
+
+# Extract a single jq field from a buffered input string. CR-stripped. Returns 1
+# when the field is empty or jq fails, so the caller can skip.
+# FIELD=$(hook::jq_field "$INPUT" '.tool_input.file_path') || exit 0
+hook::jq_field() {
+ local field
+ field=$(jq -r "(${2} // empty)"' | gsub("\r";"")' <<<"$1" 2>/dev/null)
+ [[ -n "$field" ]] || return 1
+ printf '%s' "$field"
+}
+
+# Reduce a tool + optional Bash command to a privacy-safe subject label. For
+# Bash, returns "Bash:" (leading sudo / VAR=val prefixes stripped,
+# basename applied) — never the full command. For any other tool, returns the
+# tool name unchanged. Carries no argument body, path, or command tail.
+#
+# Whitespace-splitting is only safe when no quoted value spans the whitespace.
+# A quoted assignment value (e.g. `TOKEN="a b" curl …`) would otherwise leak a
+# fragment of the value into the token, so any token carrying a quote aborts to a
+# bare "Bash" subject rather than risk exposing part of the value.
+# SUBJECT=$(hook::extract_bash_subject "$TOOL" "$CMD")
+hook::extract_bash_subject() {
+ local tool="$1" cmd="${2:-}"
+ if [[ "$tool" != "Bash" ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
+ # Trim leading whitespace so the first token is real.
+ cmd="${cmd#"${cmd%%[![:space:]]*}"}"
+ local first_token="${cmd%%[[:space:]]*}"
+ while [[ "$first_token" == "sudo" || "$first_token" == *=* ]] &&
+ [[ -n "$cmd" && "$cmd" == *[[:space:]]* ]]; do
+ # A quote in the prefix token means a quoted value spans the next whitespace;
+ # we cannot tokenize it safely — bail rather than leak a value fragment.
+ if [[ "$first_token" == *[\"\']* ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
+ cmd="${cmd#*[[:space:]]}"
+ cmd="${cmd#"${cmd%%[![:space:]]*}"}"
+ first_token="${cmd%%[[:space:]]*}"
+ done
+ # The resolved command token itself must not carry a quote (e.g. a value that
+ # ended here), which would likewise be a value fragment.
+ if [[ "$first_token" == *[\"\']* ]]; then
+ printf '%s' "$tool"
+ return 0
+ fi
+ first_token="${first_token##*/}"
+ if [[ -n "$first_token" ]]; then
+ printf 'Bash:%s' "$first_token"
+ else
+ printf '%s' "$tool"
+ fi
+}
+
+# Append one line to a JSONL file, serialized under an flock advisory lock when
+# flock is present (bounded 2s wait; a lost race drops the line rather than
+# blocking) and a best-effort bare append otherwise. Fire-and-forget: never
+# fails the caller. Used by audit hooks that maintain a bespoke second store.
+# hook::append_jsonl
+hook::append_jsonl() {
+ local file="$1" line="$2"
+ if command -v flock >/dev/null 2>&1; then
+ (
+ flock -w 2 9 || exit 0
+ printf '%s\n' "$line" >>"$file"
+ ) 9>"${file}.lock" 2>/dev/null
+ else
+ printf '%s\n' "$line" >>"$file" 2>/dev/null
+ fi
+}
+
+# Per-hook stdout context accumulator. ctx_reset at entry, ctx_append per line,
+# ctx_flush once at exit with the hook event name.
+_HOOK_CTX_BUFFER=""
+
+hook::ctx_reset() {
+ _HOOK_CTX_BUFFER=""
+}
+
+hook::ctx_append() {
+ _HOOK_CTX_BUFFER+="$1"$'\n'
+}
+
+# Emit the accumulated context as hookSpecificOutput JSON, then clear the buffer.
+hook::ctx_flush() {
+ local event_name="$1"
+ local trimmed="${_HOOK_CTX_BUFFER%"${_HOOK_CTX_BUFFER##*[![:space:]]}"}"
+ trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}"
+ hook::emit_additional_context "$event_name" "$trimmed"
+ hook::ctx_reset
+}
+
+# Cheap telemetry opt-in probe — true iff a consumer wired a sink. Producers
+# gate telemetry-payload construction on this (repo-relative path
+# normalization, data JSON) so the unwired default path spawns zero
+# telemetry-only subprocesses. Pure shell test, no subprocess.
+# hook::emit_telemetry re-checks the sink itself, so skipping this probe
+# costs only wasted payload work, never correctness.
+hook::telemetry_enabled() {
+ [[ -n "${HOOK_TELEMETRY_SINK:-}" ]]
+}
+
+# Emit one telemetry envelope per hook run to the consumer-set sink.
+# Fire-and-forget: sink is dispatched in the background; the hook never waits
+# on it and its failure never affects the hook's own exit code or stdout.
+# Opt-in guard: HOOK_TELEMETRY_SINK unset or empty → return 0 immediately.
+# Fail-open: jq absent → return 0 immediately.
+#
+# Usage:
+# hook::emit_telemetry [repo_root]
+#
+# Value of $EPOCHREALTIME captured by the caller before work began.
+# Handles both '.' and ',' as the decimal separator (LC_NUMERIC).
+# Pre-built JSON object for the `data` field.
+# Optional consuming-repo root, used to resolve a RELATIVE
+# HOOK_TELEMETRY_SINK. The caller passes the root it already
+# resolved for data.file; ignored when the sink is absolute.
+#
+# Sink path resolution: HOOK_TELEMETRY_SINK may be absolute OR relative to the
+# consuming repo root. Absolute (POSIX /… or Windows X:\ / X:/) is used as-is; a
+# relative value is joined onto (or $CLAUDE_PROJECT_DIR when no root
+# is passed), and skipped fail-open if neither is available. Relative is the
+# portable, team-shared wiring form: CC injects settings.json env values
+# literally (no ${VAR} expansion), so a relative path tracked in settings.json is
+# the only clone-portable, worktree-safe option.
+#
+# NEVER writes to fd1 (the hook's stdout / additionalContext channel).
+hook::emit_telemetry() {
+ # Opt-in guard.
+ [[ -n "${HOOK_TELEMETRY_SINK:-}" ]] || return 0
+ # Fail-open when jq is absent.
+ command -v jq >/dev/null 2>&1 || return 0
+
+ local hook_id="$1"
+ local hook_event="$2"
+ local status="$3"
+ local start_epoch="$4"
+ local data_json="$5"
+ local repo_root="${6:-}"
+
+ # Compute duration_ms from caller's $EPOCHREALTIME snapshot to now.
+ # Both '.' and ',' separators handled; 10# prefix prevents octal misreading
+ # of fractional parts with leading zeros (e.g. .045123 → 10#045123 = 45123).
+ # EPOCHREALTIME is Bash 5.0+; on an older host it (and the caller's start
+ # snapshot) is empty. Skip telemetry fail-open rather than abort under set -u —
+ # the same silent-skip the caller's `START=${EPOCHREALTIME:-}` guard intends.
+ local now=${EPOCHREALTIME:-}
+ [[ -n "$start_epoch" && -n "$now" ]] || return 0
+ local s_s="${start_epoch%[.,]*}" s_f="${start_epoch#*[.,]}"
+ local e_s="${now%[.,]*}" e_f="${now#*[.,]}"
+ local duration_ms=$(((e_s * 1000000 + 10#$e_f - s_s * 1000000 - 10#$s_f) / 1000))
+
+ # True UTC timestamp (TZ= prefix overrides LC_ALL / local TZ; the Z is not a lie).
+ local timestamp
+ timestamp=$(TZ=UTC printf '%(%Y-%m-%dT%H:%M:%SZ)T' -1)
+
+ # Build the envelope. Redirect jq stderr to /dev/null; output goes to a local
+ # variable — never to fd1.
+ local envelope
+ envelope=$(jq -n \
+ --arg schema_version "1.0" \
+ --arg timestamp "$timestamp" \
+ --arg hook "$hook_id" \
+ --arg hook_event "$hook_event" \
+ --arg status "$status" \
+ --argjson duration_ms "$duration_ms" \
+ --argjson data "$data_json" \
+ '{schema_version:$schema_version,timestamp:$timestamp,hook:$hook,hook_event:$hook_event,status:$status,duration_ms:$duration_ms,data:$data}' \
+ 2>/dev/null) || return 0
+
+ # Resolve the sink path. A relative HOOK_TELEMETRY_SINK is joined onto the
+ # consuming repo root (portable, tracked wiring); absolute is used as-is. A
+ # relative value with no anchor is skipped fail-open — never exec a path the
+ # drifted hook CWD would resolve incorrectly.
+ local sink="$HOOK_TELEMETRY_SINK"
+ case "$sink" in
+ /* | [A-Za-z]:[/\\]*) ;;
+ *)
+ local root="${repo_root:-${CLAUDE_PROJECT_DIR:-}}"
+ [[ -n "$root" ]] || return 0
+ sink="${root%/}/$sink"
+ ;;
+ esac
+
+ # Fire-and-forget: pipe the envelope to the sink in a background subshell.
+ # The subshell's stdout AND stderr are redirected to /dev/null so the sink
+ # cannot write to the hook's fd1 (the additionalContext channel) and the
+ # backgrounded subshell does not hold a copy of the hook's fd1 open — which
+ # would block any command substitution wrapping the hook until the sink exits
+ # (the "C1 fd1-inheritance blocker"). The sink is quoted — it is a single
+ # executable path (wrap in a script to pass arguments).
+ printf '%s\n' "$envelope" | ("$sink" >/dev/null 2>&1) &
+}
+
+# Print cross-host hook JSON to stdout (exit 0). No-op when context is empty.
+# Shape: { hookSpecificOutput: { hookEventName[, additionalContext] } }.
+hook::emit_additional_context() {
+ local event_name="$1"
+ local context="$2"
+ [[ -n "$context" ]] || return 0
+ command -v jq >/dev/null 2>&1 || return 0
+ jq -n \
+ --arg event "$event_name" \
+ --arg ctx "$context" \
+ '{hookSpecificOutput: (
+ {hookEventName: $event}
+ + (if $ctx != "" then {additionalContext: $ctx} else {} end)
+ )}'
+}
+
+# ---------------------------------------------------------------------------
+# Argv-grammar-faithful Bash command parsing for git guards. The command is
+# parsed the way the shell builds argv — top-level segments split on unquoted
+# control operators, each tokenized into argv words honoring '…', "…", $'…'
+# (ANSI-C), and backslash escapes — then a real git executable is resolved at
+# the segment's command position past env-var assignments and known wrappers,
+# and its subcommand resolved past git global options.
+#
+# Static matching over the literal command string only: shell variable and
+# command substitution ($VAR, $(…)) are NOT evaluated. Guards built on this
+# are friction against accidental/casual bypass, not a sandbox.
+
+# Decode an ANSI-C `$'…'` body to its literal bytes (\xHH, \NNN octal, \uHHHH,
+# \n, \\, …). %-escaped so the body can never act as a printf format specifier;
+# `--` guards a body that begins with `-`. Errors are swallowed (fail-open on a
+# malformed body — the raw text still flows through the caller unchanged).
+hook::ansi_c_decode() {
+ local b="${1//%/%%}"
+ # shellcheck disable=SC2059 # the body IS the format — that is how ANSI-C escapes decode; %-escaped above so it cannot inject a specifier
+ printf -- "$b" 2>/dev/null
+}
+
+# Split a GNU `env -S` operand the way env does: whitespace-separated words
+# honoring "…" and '…' quotes and backslash escapes — so a flag quoted inside
+# the operand (`env -S 'git push "--force"'`) still surfaces as its unquoted
+# argv word. env's $VAR expansion inside the operand is NOT evaluated (static
+# analysis over the literal string — same residual as the segment tokenizer).
+# Result in the global HOOK_ENV_S_WORDS array.
+# shellcheck disable=SC2034 # result global is consumed by hook::git_resolve_index
+# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape
+hook::env_s_split() {
+ local s="$1" i c n=${#1} word="" have=0
+ HOOK_ENV_S_WORDS=()
+ for ((i = 0; i < n; i++)); do
+ c="${s:i:1}"
+ case "$c" in
+ "'")
+ ((i++))
+ while ((i < n)) && [[ "${s:i:1}" != "'" ]]; do
+ word+="${s:i:1}"
+ ((i++))
+ done
+ have=1
+ ;;
+ '"')
+ ((i++))
+ while ((i < n)) && [[ "${s:i:1}" != '"' ]]; do
+ if [[ "${s:i:1}" == '\' ]] && ((i + 1 < n)); then
+ word+="${s:i+1:1}"
+ ((i += 2))
+ continue
+ fi
+ word+="${s:i:1}"
+ ((i++))
+ done
+ have=1
+ ;;
+ '\')
+ if ((i + 1 < n)); then
+ word+="${s:i+1:1}"
+ ((i++))
+ fi
+ have=1
+ ;;
+ ' ' | $'\t')
+ if ((have)); then
+ HOOK_ENV_S_WORDS+=("$word")
+ word=""
+ have=0
+ fi
+ ;;
+ *)
+ word+="$c"
+ have=1
+ ;;
+ esac
+ done
+ ((have)) && HOOK_ENV_S_WORDS+=("$word")
+}
+
+# Detect a `sh -c` style shell wrapper in a segment's argv: a shell at the
+# command position (after leading env-var assignments) carrying a `-c` flag.
+# On match, the command-string operand lands in HOOK_SHELL_C_OPERAND for the
+# caller to re-parse with hook::bash_parse_segments — the operand is a full
+# shell command (operators, quoting, everything), so re-parsing with the same
+# tokenizer is the faithful treatment. Wrappers stacked in front of the shell
+# (`sudo bash -c …`) are NOT resolved here — a documented residual of the
+# static-matcher posture. A shell invoked on a script file (no -c) never
+# matches: file contents cannot be inspected statically.
+# shellcheck disable=SC2034 # result global is consumed by the sourcing guard
+hook::shell_c_operand() {
+ local -a w=("$@")
+ local n=${#w[@]} i=0 b t has_c=0
+ # Skip leading VAR=val assignments, mirroring the git resolver.
+ while ((i < n)) && [[ "${w[i]}" == *=* && "${w[i]}" != -* ]]; do ((i++)); done
+ ((i < n)) || return 1
+ b="${w[i]##*/}"
+ b="${b##*\\}"
+ case "${OSTYPE:-}" in
+ msys* | cygwin* | win32)
+ b="${b,,}"
+ b="${b%.exe}"
+ ;;
+ *) ;;
+ esac
+ case "$b" in
+ bash | sh | zsh | dash | ksh | mksh) ;;
+ *) return 1 ;;
+ esac
+ ((i++))
+ while ((i < n)); do
+ t="${w[i]}"
+ case "$t" in
+ --)
+ ((i++))
+ break
+ ;;
+ # -o/-O (and +o/+O) consume a set/shopt operand; --rcfile/--init-file
+ # consume a startup-file operand (bash) — none of these ends the option
+ # scan, so `bash --rcfile /dev/null -c '…'` still reaches its -c.
+ -o | +o | -O | +O | --rcfile | --init-file) ((i += 2)) ;;
+ -*)
+ [[ "$t" =~ ^-[A-Za-z]+$ && "$t" == *c* ]] && has_c=1
+ ((i++))
+ ;;
+ *) break ;;
+ esac
+ done
+ ((has_c)) || return 1
+ ((i < n)) || return 1
+ HOOK_SHELL_C_OPERAND="${w[i]}"
+ return 0
+}
+
+# Does an argv word name the git executable? Basename compared exactly on
+# POSIX; on Windows/MSYS also case-folded and `.exe`-stripped (mirrors the
+# OS-gate in hook::normalize_path) so `GIT` / `git.exe` are caught there but a
+# case-variant stays distinct on a case-sensitive POSIX filesystem.
+hook::git_is_bin() {
+ local b="${1##*/}"
+ b="${b##*\\}"
+ case "${OSTYPE:-}" in
+ msys* | cygwin* | win32)
+ local lc="${b,,}"
+ lc="${lc%.exe}"
+ [[ "$lc" == "git" ]]
+ ;;
+ *) [[ "$b" == "git" ]] ;;
+ esac
+}
+
+# Locate a real `git` executable at the segment's command position (after
+# env-var prefixes and known wrappers), or return 1 when absent. Results go in
+# globals, NOT a $( ) echo: `env -S` splicing rewrites the argv, and the caller
+# must match on the rewritten words, so the index alone is not enough.
+# HOOK_GIT_RESOLVED_GI — index of git in HOOK_GIT_RESOLVED_WORDS
+# HOOK_GIT_RESOLVED_WORDS — the (possibly rewritten) segment argv
+# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape
+# shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file
+hook::git_resolve_index() {
+ HOOK_GIT_RESOLVED_WORDS=("$@")
+ HOOK_GIT_RESOLVED_GI=-1
+ # shellcheck disable=SC2178 # nameref to the array result global, not a string assignment
+ local -n w=HOOK_GIT_RESOLVED_WORDS
+ local n=${#w[@]} i=0 tok
+
+ while ((i < n)); do
+ tok="${w[i]}"
+ if [[ "$tok" == *=* ]]; then
+ ((i++))
+ continue
+ fi
+
+ case "${tok##*/}" in
+ env)
+ ((i++))
+ while ((i < n)) && [[ "${w[i]}" == -* ]]; do
+ case "${w[i]}" in
+ # -S/--split-string re-splits its operand into argv (GNU env), so a
+ # quoted 'git commit --no-verify' would otherwise hide from the
+ # resolver as one non-git word. Splice the split words back into the
+ # scan and restart at the command position.
+ -S | --split-string)
+ local sval=""
+ ((i + 1 < n)) && sval="${w[i + 1]}"
+ hook::env_s_split "$sval"
+ w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+2}")
+ n=${#w[@]}
+ i=0
+ continue 2
+ ;;
+ -S* | --split-string=*)
+ local sval="${w[i]#-S}"
+ sval="${sval#--split-string=}"
+ hook::env_s_split "$sval"
+ w=(${HOOK_ENV_S_WORDS[@]+"${HOOK_ENV_S_WORDS[@]}"} "${w[@]:i+1}")
+ n=${#w[@]}
+ i=0
+ continue 2
+ ;;
+ -u | --unset | -C | --chdir) ((i += 2)) ;;
+ -*) ((i++)) ;;
+ *) ((i++)) ;;
+ esac
+ done
+ continue
+ ;;
+ nice | nohup)
+ ((i++))
+ while ((i < n)) && [[ "${w[i]}" == -* ]]; do
+ case "${w[i]}" in
+ -n | --adjustment) ((i += 2)) ;;
+ --adjustment=*) ((i++)) ;;
+ -*) ((i++)) ;;
+ *) break ;;
+ esac
+ done
+ if ((i < n)) && [[ "${w[i]}" =~ ^-?[0-9]+$ ]]; then
+ ((i++))
+ fi
+ continue
+ ;;
+ sudo)
+ ((i++))
+ while ((i < n)) && [[ "${w[i]}" == -* ]]; do
+ case "${w[i]}" in
+ -u | -g | -h | -p | -C | -D | -R | -T | --user | --group | --chdir) ((i += 2)) ;;
+ -*) ((i++)) ;;
+ *) ((i++)) ;;
+ esac
+ done
+ continue
+ ;;
+ timeout)
+ ((i++))
+ while ((i < n)) && [[ "${w[i]}" == -* ]]; do
+ case "${w[i]}" in
+ -s | --signal | -k | --kill-after) ((i += 2)) ;;
+ --preserve-status | --foreground | --verbose) ((i++)) ;;
+ -*) ((i++)) ;;
+ *) break ;;
+ esac
+ done
+ if ((i < n)) && [[ "${w[i]}" =~ ^[0-9]+([.][0-9]+)?(s|m|h|d)?$ ]]; then
+ ((i++))
+ fi
+ continue
+ ;;
+ # eval concatenates and re-executes its arguments, so for the unquoted
+ # form (`eval git commit ...`) scanning the following words is exact.
+ # command/exec carry their own options before the real command
+ # (`command [-pVv]`, `exec [-cl] [-a name]`) and an optional `--`
+ # end-of-options marker (`command -- git …`) — skip them so the git
+ # command behind the wrapper is still resolved. But `command -v`/`-V`
+ # only PRINT the command's path/description — nothing runs — so a git
+ # word behind them is a probe, not an invocation: bail out.
+ command | exec | builtin | eval | !)
+ local is_command=0
+ [[ "${tok##*/}" == "command" ]] && is_command=1
+ ((i++))
+ while ((i < n)) && [[ "${w[i]}" == -* && "${w[i]}" != "--" ]]; do
+ if ((is_command)) && [[ "${w[i]}" == -*[vV]* ]]; then
+ return 1
+ fi
+ [[ "${w[i]}" == "-a" ]] && ((i++))
+ ((i++))
+ done
+ ((i < n)) && [[ "${w[i]}" == "--" ]] && ((i++))
+ continue
+ ;;
+ time)
+ ((i++))
+ if ((i < n)) && [[ "${w[i]}" == "-p" ]]; then
+ ((i++))
+ fi
+ continue
+ ;;
+ # Compound-command reserved words at the execution position: a command
+ # can directly follow any of these within one segment (`if git …`,
+ # `then git …`, `do git …`), so skip them and keep resolving.
+ if | then | elif | else | while | until | for | do | case | select | coproc | '{' | '}')
+ ((i++))
+ continue
+ ;;
+ *)
+ if hook::git_is_bin "$tok"; then
+ HOOK_GIT_RESOLVED_GI=$i
+ return 0
+ fi
+ return 1
+ ;;
+ esac
+ done
+ return 1
+}
+
+# Resolve the git subcommand in an already-resolved segment: walk words after
+# the git executable, skipping git global options. The listed options consume
+# the FOLLOWING word as their value (two-word form); their =-forms and every
+# other option are single words handled by the generic `-*` skip. Results in
+# globals:
+# HOOK_GIT_SUB — the subcommand word ("" when none found)
+# HOOK_GIT_SUB_IDX — its index in the argv (-1 when none)
+# HOOK_GIT_CONFIG_VALUES — values of -c/--config/--config-env options, in
+# order, so a guard can inspect config assignments
+# without re-walking (commit messages and pathspecs
+# are never collected here)
+# Call as: hook::git_resolve_subcommand
+# shellcheck disable=SC2034 # result globals are consumed by the sourcing guard, not this file
+hook::git_resolve_subcommand() {
+ local gi="$1"
+ shift
+ local -a w=("$@")
+ local nseg=${#w[@]} j gw
+ HOOK_GIT_SUB=""
+ HOOK_GIT_SUB_IDX=-1
+ HOOK_GIT_CONFIG_VALUES=()
+
+ j=$((gi + 1))
+ while ((j < nseg)); do
+ gw="${w[j]}"
+ case "$gw" in
+ -c | --config | --config-env)
+ ((j + 1 < nseg)) && HOOK_GIT_CONFIG_VALUES+=("${w[j + 1]}")
+ ((j += 2))
+ ;;
+ --config=* | --config-env=*)
+ HOOK_GIT_CONFIG_VALUES+=("${gw#*=}")
+ ((j++))
+ ;;
+ -C | --git-dir | --work-tree | --namespace | --super-prefix | --attr-source | --exec-path)
+ ((j += 2))
+ ;;
+ -*)
+ ((j++))
+ ;;
+ *)
+ HOOK_GIT_SUB="$gw"
+ HOOK_GIT_SUB_IDX=$j
+ return 0
+ ;;
+ esac
+ done
+ return 1
+}
+
+# Single linear pass: read the command into a char array once (O(n)), then walk
+# it splitting top-level segments on UNQUOTED control operators and tokenizing
+# each segment into argv words honoring '…', "…", $'…', and backslash escapes
+# (including backslash-newline continuation). Each completed segment is passed
+# to the callback as it closes, so no full segment list is retained.
+# Call as: hook::bash_parse_segments ; the callback
+# receives one segment's argv words as "$@".
+# shellcheck disable=SC1003 # '\' compares a literal backslash char, not a quote escape
+hook::bash_parse_segments() {
+ local cmd="$1" cb="$2"
+ local -a chars=()
+ local c nx
+ while IFS= read -rN1 c; do chars+=("$c"); done < <(printf '%s' "$cmd")
+ local n=${#chars[@]} i
+ local word="" have=0 skipnext=0
+ local -a seg=()
+ # Pending heredoc delimiters (FIFO) and their `<<-` tab-strip flags. A
+ # heredoc body is the command's stdin, not commands — recorded when `<<`
+ # is seen and skipped wholesale at the command-line newline.
+ local -a hd_delims=() hd_strip=()
+
+ for ((i = 0; i < n; i++)); do
+ c="${chars[i]}"
+ case "$c" in
+ "'")
+ ((i++))
+ while ((i < n)) && [[ "${chars[i]}" != "'" ]]; do
+ word+="${chars[i]}"
+ ((i++))
+ done
+ have=1
+ ;;
+ '"')
+ ((i++))
+ while ((i < n)) && [[ "${chars[i]}" != '"' ]]; do
+ if [[ "${chars[i]}" == '\' ]] && ((i + 1 < n)); then
+ nx="${chars[i + 1]}"
+ case "$nx" in
+ '"' | '\' | '$' | '`')
+ word+="$nx"
+ ((i += 2))
+ continue
+ ;;
+ $'\n')
+ ((i += 2))
+ continue
+ ;;
+ *) ;;
+ esac
+ fi
+ word+="${chars[i]}"
+ ((i++))
+ done
+ have=1
+ ;;
+ '$')
+ if ((i + 1 < n)) && [[ "${chars[i + 1]}" == "'" ]]; then
+ i=$((i + 2))
+ local body=""
+ while ((i < n)) && [[ "${chars[i]}" != "'" ]]; do
+ if [[ "${chars[i]}" == '\' ]] && ((i + 1 < n)); then
+ body+="${chars[i]}${chars[i + 1]}"
+ ((i += 2))
+ continue
+ fi
+ body+="${chars[i]}"
+ ((i++))
+ done
+ word+="$(hook::ansi_c_decode "$body")"
+ have=1
+ else
+ word+="$c"
+ have=1
+ fi
+ ;;
+ '\')
+ if ((i + 1 < n)); then
+ nx="${chars[i + 1]}"
+ if [[ "$nx" == $'\n' ]]; then
+ ((i++))
+ else
+ word+="$nx"
+ ((i++))
+ have=1
+ fi
+ else
+ have=1
+ fi
+ ;;
+ ' ' | $'\t')
+ if ((have)); then
+ if ((skipnext)); then skipnext=0; else seg+=("$word"); fi
+ word=""
+ have=0
+ fi
+ ;;
+ '>' | '<')
+ # Redirection: bash removes the operator and its target word from
+ # argv (redirections may appear anywhere in a simple command), so
+ # `git reset --hard>/tmp/out` still runs reset --hard. A pure-digit
+ # word immediately before the operator is its fd prefix, not argv;
+ # an fd-dup/close form (`2>&1`, `>&-`) has no target word to skip.
+ if ((have)); then
+ if [[ "$word" =~ ^[0-9]+$ ]]; then
+ :
+ elif ((skipnext)); then
+ skipnext=0
+ else
+ seg+=("$word")
+ fi
+ word=""
+ have=0
+ fi
+ # Heredoc `<<` / `<<-` (but NOT here-string `<<<`): the body on the
+ # following lines is the command's stdin, so record the delimiter and
+ # let the newline handler skip the body. A quoted/backslashed delimiter
+ # (`<<'EOF'`, `<<\EOF`) still terminates on a line reading `EOF`.
+ if [[ "$c" == '<' ]] && ((i + 1 < n)) && [[ "${chars[i + 1]}" == '<' ]] \
+ && { ((i + 2 >= n)) || [[ "${chars[i + 2]}" != '<' ]]; }; then
+ ((i++))
+ local hstrip=0
+ if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '-' ]]; then
+ hstrip=1
+ ((i++))
+ fi
+ while ((i + 1 < n)) && [[ "${chars[i + 1]}" == ' ' || "${chars[i + 1]}" == $'\t' ]]; do ((i++)); done
+ local delim=""
+ while ((i + 1 < n)); do
+ nx="${chars[i + 1]}"
+ case "$nx" in
+ ' ' | $'\t' | $'\n' | ';' | '&' | '|' | '<' | '>') break ;;
+ "'")
+ ((i++))
+ while ((i + 1 < n)) && [[ "${chars[i + 1]}" != "'" ]]; do
+ delim+="${chars[i + 1]}"
+ ((i++))
+ done
+ ((i + 1 < n)) && ((i++))
+ ;;
+ '"')
+ ((i++))
+ while ((i + 1 < n)) && [[ "${chars[i + 1]}" != '"' ]]; do
+ delim+="${chars[i + 1]}"
+ ((i++))
+ done
+ ((i + 1 < n)) && ((i++))
+ ;;
+ '\')
+ ((i++))
+ ((i + 1 < n)) && {
+ delim+="${chars[i + 1]}"
+ ((i++))
+ }
+ ;;
+ *)
+ delim+="$nx"
+ ((i++))
+ ;;
+ esac
+ done
+ hd_delims+=("$delim")
+ hd_strip+=("$hstrip")
+ continue
+ fi
+ if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '(' ]]; then
+ # Process substitution <(list)/>(list): the list is a real command
+ # substituted as a filename — it satisfies any pending target and
+ # the '(' separator splits it into a segment that gets scanned.
+ skipnext=0
+ else
+ while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [\<\>] ]]; do ((i++)); done
+ if ((i + 1 < n)) && [[ "${chars[i + 1]}" == '&' ]]; then
+ ((i++))
+ if ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; then
+ while ((i + 1 < n)) && [[ "${chars[i + 1]}" == [0-9-] ]]; do ((i++)); done
+ else
+ skipnext=1
+ fi
+ else
+ skipnext=1
+ fi
+ fi
+ ;;
+ ';' | '&' | '|' | '(' | ')' | '`' | $'\n')
+ if ((have)); then
+ if ((skipnext)); then skipnext=0; else seg+=("$word"); fi
+ word=""
+ have=0
+ fi
+ if ((${#seg[@]})); then
+ "$cb" "${seg[@]}"
+ seg=()
+ fi
+ # A command-line newline ends the line that introduced any pending
+ # heredocs; their bodies (up to and including each delimiter line) are
+ # stdin, so consume them without tokenizing. Delimiters match in FIFO
+ # order; `<<-` strips leading tabs from body lines before comparing.
+ if [[ "$c" == $'\n' ]] && ((${#hd_delims[@]})); then
+ local hidx line lc d strip
+ for ((hidx = 0; hidx < ${#hd_delims[@]}; hidx++)); do
+ d="${hd_delims[hidx]}"
+ strip="${hd_strip[hidx]}"
+ while ((i + 1 < n)); do
+ line=""
+ while ((i + 1 < n)) && [[ "${chars[i + 1]}" != $'\n' ]]; do
+ line+="${chars[i + 1]}"
+ ((i++))
+ done
+ ((i + 1 < n)) && ((i++))
+ lc="$line"
+ if ((strip)); then
+ while [[ "$lc" == $'\t'* ]]; do lc="${lc#?}"; done
+ fi
+ [[ "$lc" == "$d" ]] && break
+ done
+ done
+ hd_delims=()
+ hd_strip=()
+ fi
+ ;;
+ *)
+ word+="$c"
+ have=1
+ ;;
+ esac
+ done
+ if ((have)) && ((!skipnext)); then seg+=("$word"); fi
+ if ((${#seg[@]})); then "$cb" "${seg[@]}"; fi
+}
diff --git a/plugins/go-format/hooks/hooks.json b/plugins/go-format/hooks/hooks.json
new file mode 100644
index 000000000..964d92025
--- /dev/null
+++ b/plugins/go-format/hooks/hooks.json
@@ -0,0 +1,16 @@
+{
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Write|Edit",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "\"${CLAUDE_PLUGIN_ROOT}\"/hooks/go-format.sh",
+ "timeout": 15
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/plugins/go-format/skills/setup/SKILL.md b/plugins/go-format/skills/setup/SKILL.md
new file mode 100644
index 000000000..511e98511
--- /dev/null
+++ b/plugins/go-format/skills/setup/SKILL.md
@@ -0,0 +1,81 @@
+---
+name: setup
+description: "Verify the go-format hook's runtime prerequisites and configuration for this repository. Use when: 'set up go-format', 'configure go-format', 'is go-format working', Go import/formatting fixes silently aren't happening, or the hook reported a missing prerequisite. Actions: check (read-only verification, default) | apply (resolve what check found). Re-runnable and safe."
+argument-hint: "check | apply"
+user-invocable: true
+disable-model-invocation: true
+---
+
+## Purpose
+
+Thin check-centric setup per the uniform contract: `check` inspects and reports, `apply`
+resolves. This plugin owns no consumer-project configuration — it runs unconditionally (no
+consumer-config opt-in gate, unlike sibling formatter plugins Ruff/typos), so the only tunable
+is the native `userConfig` toggle. Like `typos-format`, `goimports` has no per-repo
+dependency-manager install path in the way Ruff's `.venv` does — it is conventionally
+`go install`ed to the machine-global `$GOPATH/bin`, never as a project dependency. `apply` is
+therefore guidance-only: it never installs anything, matching the hook's own PATH-only
+resolution and the plugin philosophy's never-download-silently rule.
+
+Action routing: no argument or `check` runs the check; `apply` runs the check first, then
+prints remediation guidance for each FAIL. Both are non-interactive — never prompt when the
+action is given.
+
+## `check` (read-only)
+
+The hook script (`${CLAUDE_PLUGIN_ROOT}/hooks/go-format.sh`) is the single source of truth for
+what it requires and how it resolves things. **Read it first** — probe what it actually does,
+don't recite this file. Then run each probe via Bash and report a PASS/FAIL/INFO table with one
+remediation line per FAIL. Do not modify anything.
+
+When the plugin's toggle is disabled, every prerequisite absence downgrades from FAIL to
+INFO — the hook exits through its enabled-gate before probing anything, so a deliberately
+disabled plugin is not broken. Report the probes informationally and note that re-enabling
+restores the FAIL semantics.
+
+1. **Bash version** — check against the hook's documented floor (README Requirements),
+ noting any features the hook degrades without (telemetry's `EPOCHREALTIME`, Bash 5.0+).
+2. **`jq`** — `command -v jq`. FAIL if absent: the hook then skips with a visible
+ once-per-session notice instead of formatting.
+3. **`goimports` binary** — `command -v goimports` (the hook resolves PATH only — no
+ `.venv`-style per-repo convention). Report the resolved path and `goimports -h`'s first line
+ when found (goimports has no `--version` flag; the help header is the closest signal). FAIL
+ when absent — the hook then emits a visible once-per-session skip notice instead of running.
+4. **Hook toggle** — report the effective `go_format_enabled` value:
+ `${user_config.go_format_enabled}` (unexpanded or empty means default `true`).
+5. **Hook registration** — INFO: confirm the plugin is enabled for this project
+ (`/plugin` → Installed) rather than parsing settings files.
+
+There is no consumer-config probe (unlike `typos-format`'s config-walk check) — this hook runs
+unconditionally by design; report that plainly as INFO, not as a gap.
+
+## `apply` (idempotent)
+
+Run `check`, then for each FAIL print remediation guidance — never install anything. There is
+no `apply install-goimports`-style write path: `go install golang.org/x/tools/cmd/goimports@latest`
+writes to the machine-global `$GOPATH/bin` (not project-scoped) and `@latest` is not
+idempotent-pinned, so the only responsible action is pointing at the command and letting the
+consumer run it themselves.
+
+After the consumer installs `goimports` themselves, re-run `check` and report its actual
+result — never claim resolved without re-verifying. For everything else `apply` only points:
+
+- missing `goimports`: `go install golang.org/x/tools/cmd/goimports@latest` (requires a Go
+ toolchain: https://go.dev/dl/).
+- missing `jq` / Bash: platform install instructions from the README Requirements section;
+ this skill never installs system packages.
+- toggle off: direct to `/plugin configure go-format` (interactive, any
+ time). Headless: `--config` only applies on a fresh install (ignored once installed), so
+ reconfigure via `claude plugin uninstall go-format` then
+ `claude plugin install go-format@ --config go_format_enabled=true`;
+ this skill never writes user settings or `pluginConfigs`.
+
+Re-running `apply` after everything passes changes nothing and reports "already configured".
+
+## What this skill does NOT do
+
+- Run the formatter — editing any `.go` file exercises the hook end-to-end.
+- Write the plugin cache, Claude Code user settings, or `pluginConfigs`.
+- Install `goimports` — installation is always the consumer's own choice and command, at the
+ machine level, never a project dependency this skill records.
+- Download or execute tools during `check` or `apply`.
diff --git a/plugins/toolchain/.claude-plugin/plugin.json b/plugins/toolchain/.claude-plugin/plugin.json
index 9313543ec..95ba05c3f 100644
--- a/plugins/toolchain/.claude-plugin/plugin.json
+++ b/plugins/toolchain/.claude-plugin/plugin.json
@@ -1,8 +1,8 @@
{
"$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
"name": "toolchain",
- "version": "0.7.0",
- "description": "Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).",
+ "version": "0.8.0",
+ "description": "Repo-agnostic polyglot verification toolchain: build + test + lint for changed files across .NET, Python, TypeScript, Bash, PowerShell, Markdown, Go, YAML, and cross-cutting surfaces (`/toolchain:check`, `/toolchain:lint`), plus a re-runnable `/toolchain:setup` with check (report the configured ecosystems and their command surface) and apply (interview, infer, and write the tracked per-ecosystem command config those skills resolve first).",
"author": {
"name": "Melodic Software",
"email": "info@melodicsoftware.com"
diff --git a/plugins/toolchain/CHANGELOG.md b/plugins/toolchain/CHANGELOG.md
index 7a9373314..2e371480c 100644
--- a/plugins/toolchain/CHANGELOG.md
+++ b/plugins/toolchain/CHANGELOG.md
@@ -3,6 +3,24 @@
All notable changes to the `toolchain` plugin are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning.
+## [0.8.0]
+
+### Added
+
+- **`go` ecosystem batch default** (`build-cmd: go build ./...`, `test-cmd: go test ./...`,
+ `check-cmd`/`fix-cmd: golangci-lint run [--fix] ./...`, `project-discovery: ["go.mod"]` for
+ nested-module coverage, a `go-mod-tidy-drift` gate via `go mod tidy -diff`) added to
+ `reference/ecosystems/go.yaml` — closes the Go toolchain CI/local-parity gap. Gated behind an
+ `opt-in` key (`.golangci.yml`/`.golangci.yaml`/`.golangci.toml`/`.golangci.json` presence) —
+ empirically verified golangci-lint v2 with no config file still applies its own fixed "standard"
+ linter preset unconditionally, the same imposed-unconfigured-opinion risk the 0.6.0 dotnet gate
+ addressed.
+- `context/go.md` reference file — Go-specific gotchas (`./...` module-boundary behavior,
+ golangci-lint's home-directory config fallback, `go mod tidy -diff`'s Go 1.23+ requirement).
+- `docs/conventions/ecosystem-commands/examples/go.yaml` worked-example fixture.
+- `go`/`golang` added to `/toolchain:check` and `/toolchain:lint`'s covered-ecosystem lists and
+ alias tables.
+
## [0.7.0]
### Added
diff --git a/plugins/toolchain/reference/ecosystems/go.yaml b/plugins/toolchain/reference/ecosystems/go.yaml
new file mode 100644
index 000000000..ad7efa790
--- /dev/null
+++ b/plugins/toolchain/reference/ecosystems/go.yaml
@@ -0,0 +1,45 @@
+# Bundled portable default — go. Rung-4 fallback ONLY (a consuming repo's
+# .claude/ecosystems/go.yaml overrides this key-by-key; this file is never
+# written into a consumer repo). Ecosystem-commands contract + schema:
+# https://raw.githubusercontent.com/melodic-software/claude-code-plugins/main/docs/conventions/ecosystem-commands/README.md
+#
+# project-discovery: a root go.mod bounds `./...` package-pattern expansion —
+# verified empirically (Go 1.26.5) that go build/test/list ./... from a repo
+# root silently skip a nested module's packages (a nested go.mod, or even a
+# root go.work listing both modules, does not cross that boundary for `...`
+# expansion). Without walking to each go.mod, a monorepo with a nested Go
+# module gets silent incomplete build/test/lint.
+#
+# go-mod-tidy-drift trigger-globs includes *.go, not just go.mod/go.sum:
+# `go mod tidy` can flag drift from a source-only edit (e.g. removing the
+# last usage of a dependency leaves go.mod over-declared) that `go build`/
+# `go test` do not fail on, so scoping the gate to go.mod/go.sum changes
+# alone would miss exactly the CI/local-parity gap this ecosystem entry
+# exists to close.
+#
+# opt-in rationale: golangci-lint v2 with NO config file present still runs
+# its own fixed "standard" linter preset (linters.default: standard) rather
+# than erroring or running nothing — i.e. it imposes an opinionated
+# diagnostic set nobody explicitly chose, the same risk class the dotnet
+# ecosystem's opt-in gate addresses for unconfigured Roslyn formatting
+# defaults, and consistent with python's ruff gate below. Note: unlike
+# dotnet's EditorConfig discovery, golangci-lint's own config search has no
+# root-boundary marker — it walks from the target up to the filesystem root
+# and then falls back to the user's home directory, so a stray
+# ~/.golangci.yml can make a local run diverge from a clean CI container
+# (documented in context/go.md, not addressed by this opt-in gate, which only
+# ceilings the *this-plugin's* opt-in check at the repo root).
+globs: ["*.go", "go.mod", "go.sum"]
+project-discovery: ["go.mod"]
+build-cmd: "go build ./..."
+test-cmd: "go test ./..."
+check-cmd: "golangci-lint run ./..."
+fix-cmd: "golangci-lint run --fix ./..."
+opt-in: ".golangci.yml, .golangci.yaml, .golangci.toml, or .golangci.json present (walked from the changed file up to the repo root) — otherwise golangci-lint applies its own unconfigured \"standard\" linter preset unconditionally"
+install-hint: "Install golangci-lint: https://golangci-lint.run/docs/welcome/install/ | Go toolchain: https://go.dev/dl/"
+gates:
+ - name: go-mod-tidy-drift
+ cmd: "go mod tidy -diff"
+ trigger-globs: ["go.mod", "go.sum", "*.go"]
+ remediation: "Run go mod tidy and commit the updated go.mod/go.sum. (go mod tidy -diff requires Go 1.23+; on an older toolchain the gate errors on the unrecognized flag rather than reporting drift.)"
+notes: "govulncheck is intentionally not a rung-4 default (per the epic brief's \"optional\" framing) — add it as a consumer-local gate via .claude/ecosystems/go.local.yaml if desired."
diff --git a/plugins/toolchain/skills/check/SKILL.md b/plugins/toolchain/skills/check/SKILL.md
index 357e1be9f..8c94d022f 100644
--- a/plugins/toolchain/skills/check/SKILL.md
+++ b/plugins/toolchain/skills/check/SKILL.md
@@ -24,11 +24,11 @@ Detects affected ecosystems from changed files and runs each one's build → tes
`$ARGUMENTS` — optional ecosystem filter. If provided, run only that ecosystem. If omitted, auto-detect from changed files.
-Available ecosystem filters are the ecosystems `/toolchain:check` covers: `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown` (resolved per the ladder). Common aliases: `ts`/`node` → `typescript`, `shell` → `bash`, `ps`/`pwsh` → `powershell`, `md` → `markdown`. Literal `all` runs every covered ecosystem. The lint-only `yaml` and `cross-cutting` surfaces are **not** run by `/toolchain:check` — use `/toolchain:lint` for those.
+Available ecosystem filters are the ecosystems `/toolchain:check` covers: `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `go` (resolved per the ladder). Common aliases: `ts`/`node` → `typescript`, `shell` → `bash`, `ps`/`pwsh` → `powershell`, `md` → `markdown`, `golang` → `go`. Literal `all` runs every covered ecosystem. The lint-only `yaml` and `cross-cutting` surfaces are **not** run by `/toolchain:check` — use `/toolchain:lint` for those.
## Ecosystem detection
-Each ecosystem declares a list of `globs` that classify changed files into that ecosystem (resolved per the ladder — consumer `.claude/ecosystems/.yaml` when present, else the bundled default). The skill matches `git status --porcelain` output against each covered ecosystem's `globs` to determine which ecosystems are affected. `/toolchain:check` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`; the lint-only `yaml` and `cross-cutting` surfaces are `/toolchain:lint`'s (in particular `cross-cutting`'s `**` glob is never matched here).
+Each ecosystem declares a list of `globs` that classify changed files into that ecosystem (resolved per the ladder — consumer `.claude/ecosystems/.yaml` when present, else the bundled default). The skill matches `git status --porcelain` output against each covered ecosystem's `globs` to determine which ecosystems are affected. `/toolchain:check` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `go`; the lint-only `yaml` and `cross-cutting` surfaces are `/toolchain:lint`'s (in particular `cross-cutting`'s `**` glob is never matched here).
For ecosystem-specific gotchas, reference files, and primary-source detail, read the corresponding context file:
@@ -38,6 +38,7 @@ For ecosystem-specific gotchas, reference files, and primary-source detail, read
- [context/typescript.md](context/typescript.md) — TypeScript compile, test, lint
- [context/bash.md](context/bash.md) — ShellCheck, shfmt
- [context/powershell.md](context/powershell.md) — PSScriptAnalyzer
+- [context/go.md](context/go.md) — Go build, test, lint, module discovery
When invoked as a task (`/toolchain:check`), detect from `git status --porcelain`. When referenced by another skill, use the file list that skill provides.
@@ -53,7 +54,7 @@ All commands use absolute paths. Never `cd` and lose context.
### 1. Detect ecosystems
-If `$ARGUMENTS` specifies an ecosystem, use it. If `all`, run every covered ecosystem. Otherwise, classify changed files from `git status --porcelain` against each covered ecosystem's `globs` (resolved per the ladder; `/toolchain:check` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`). Skip any ecosystem whose resolved `enabled` is `false` (a consumer opt-out) — excluded even under `all`.
+If `$ARGUMENTS` specifies an ecosystem, use it. If `all`, run every covered ecosystem. Otherwise, classify changed files from `git status --porcelain` against each covered ecosystem's `globs` (resolved per the ladder; `/toolchain:check` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `go`). Skip any ecosystem whose resolved `enabled` is `false` (a consumer opt-out) — excluded even under `all`.
If the working tree is clean, fall back to the branch diff so checkpoint-committed work still gets classified (the common pre-PR case: every green block was already committed). Resolve the default branch by **detection, not assumption** — never a hardcoded `main`/`master` — and assign it before use:
@@ -111,7 +112,7 @@ Tool presence: before each ecosystem runs, verify the tool is on `PATH`. If miss
**Opt-in gate (lint phase only)**: before running an ecosystem's `check-cmd`, evaluate its resolved `opt-in` condition (if present) against the repo. Build and test always run regardless of `opt-in` — only the lint phase is gated, since compiling and testing don't depend on style configuration.
-This binary gate applies cleanly when `opt-in` describes ONE condition governing the whole `check-cmd` (e.g. dotnet, python): unmet → report the ecosystem's Lint column as `skip (opt-in unmet: )` — visible, not silently omitted — and do not run `check-cmd`. Met → run `check-cmd` normally.
+This binary gate applies cleanly when `opt-in` describes ONE condition governing the whole `check-cmd` (e.g. dotnet, python, go): unmet → report the ecosystem's Lint column as `skip (opt-in unmet: )` — visible, not silently omitted — and do not run `check-cmd`. Met → run `check-cmd` normally.
When `opt-in` instead describes MULTIPLE independent per-tool conditions bundled into one opaque command string (e.g. bash's `"shellcheck always applies to shell files; shfmt only when .editorconfig declares shell style"`, where `check-cmd` is `shellcheck ... && shfmt -d `), this gate does NOT apply — `check-cmd` is a single opaque string (per the ecosystem-commands contract) with no way to run one sub-tool's portion without the other. Run `check-cmd` as before (unchanged from prior behavior) and report its real output; do not attempt a partial skip. See Gotchas below for the known atomicity limitation this leaves open.
diff --git a/plugins/toolchain/skills/check/context/go.md b/plugins/toolchain/skills/check/context/go.md
new file mode 100644
index 000000000..49ac8bcf4
--- /dev/null
+++ b/plugins/toolchain/skills/check/context/go.md
@@ -0,0 +1,56 @@
+# Go Build Commands
+
+## Build
+
+```bash
+cd "$PROJECT_DIR" && go build ./...
+```
+
+## Test
+
+```bash
+cd "$PROJECT_DIR" && go test ./...
+```
+
+## Lint / Format
+
+Opt-in gated: only runs when a governing `.golangci.yml`/`.golangci.yaml`/`.golangci.toml`/
+`.golangci.json` is present (see the `opt-in` key and its header-comment rationale in
+`reference/ecosystems/go.yaml`) — otherwise skipped visibly rather than imposing golangci-lint's
+own unconfigured "standard" linter preset on a repo that never configured any.
+
+```bash
+# Check (CI mode — fails on violations)
+cd "$PROJECT_DIR" && golangci-lint run ./...
+
+# Fix
+cd "$PROJECT_DIR" && golangci-lint run --fix ./...
+```
+
+## Gotchas
+
+- **`./...` is module-bounded, not repo-bounded** — `go build ./...`/`go test ./...`/
+ `go list ./...` run from a repo root silently skip a *nested* module's packages. A nested
+ `go.mod`, or even a root `go.work` file listing both modules, does not cross that boundary for
+ `./...` expansion (empirically verified, Go 1.26.5). `project-discovery: ["go.mod"]` in
+ `go.yaml` handles this by walking to each discovered module root — always run build/test/lint
+ from the module root containing the relevant `go.mod`, not just the repo root.
+- **golangci-lint's config discovery has no `root = true`-equivalent stop marker** — unlike
+ EditorConfig, it walks from the target up to the filesystem root and then falls back to the
+ user's **home directory**. A stray `~/.golangci.yml` on a developer's machine can make a local
+ run diverge from a clean CI container that has no such file. This plugin's own `opt-in` gate
+ only ceilings *its* presence check at the repo root — it does not (and cannot) suppress
+ golangci-lint's own home-directory fallback once the tool actually runs.
+- **`go mod tidy -diff` requires Go 1.23+** — the `go-mod-tidy-drift` gate in `go.yaml` uses this
+ flag; on an older toolchain it will error rather than report drift.
+- **GOFLAGS** — a repo-level `GOFLAGS` env var or `go env -w GOFLAGS=...` setting changes build/test
+ behavior repo-wide (e.g. `-mod=readonly`); check for one before assuming a bare command failure
+ is a real break.
+
+## Project discovery
+
+Find all Go modules dynamically:
+
+```bash
+find "$REPO_ROOT" -name "go.mod" -not -path "*/vendor/*"
+```
diff --git a/plugins/toolchain/skills/check/evals/evals.json b/plugins/toolchain/skills/check/evals/evals.json
index 37b1d461f..cf37fe912 100644
--- a/plugins/toolchain/skills/check/evals/evals.json
+++ b/plugins/toolchain/skills/check/evals/evals.json
@@ -42,10 +42,10 @@
"id": 4,
"name": "build-scope-excludes-lint-only-surfaces",
"prompt": "/toolchain:check all — the working tree changed a GitHub Actions workflow YAML and a plain README, among other files.",
- "expected_output": "Runs only the ecosystems /toolchain:check covers (dotnet, python, typescript, bash, powershell, markdown). It does NOT run the lint-only `yaml` or `cross-cutting` surfaces — those belong to /toolchain:lint, and cross-cutting's `**` glob is never matched here.",
+ "expected_output": "Runs only the ecosystems /toolchain:check covers (dotnet, python, typescript, bash, powershell, markdown, go). It does NOT run the lint-only `yaml` or `cross-cutting` surfaces — those belong to /toolchain:lint, and cross-cutting's `**` glob is never matched here.",
"files": [],
"expectations": [
- "Runs only the six ecosystems `/toolchain:check` covers (dotnet, python, typescript, bash, powershell, markdown)",
+ "Runs only the seven ecosystems `/toolchain:check` covers (dotnet, python, typescript, bash, powershell, markdown, go)",
"Does NOT run the `yaml` surface for the workflow file — that surface is `/toolchain:lint`-only",
"Does NOT run the `cross-cutting` surface — its `**` glob is never matched by `/toolchain:check` (both `yaml` and `cross-cutting` are `/toolchain:lint`-only surfaces)"
]
diff --git a/plugins/toolchain/skills/lint/SKILL.md b/plugins/toolchain/skills/lint/SKILL.md
index f31e4c96b..4a93e0bc9 100644
--- a/plugins/toolchain/skills/lint/SKILL.md
+++ b/plugins/toolchain/skills/lint/SKILL.md
@@ -31,7 +31,7 @@ Use `/toolchain:lint` for quick feedback during development. Use `/verification:
**Ecosystem filters** (if omitted, auto-detect from changed files):
-`/toolchain:lint` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `yaml`, and `cross-cutting` (each resolved per the ladder); any with matching files is exposed as a filter. Aliases: `py` → `python`; `ts`/`node` → `typescript`; `shell` → `bash`; `ps`/`pwsh` → `powershell`; `md` → `markdown`; `xc`/`text` → `cross-cutting`. Literal `all` runs every applicable ecosystem.
+`/toolchain:lint` covers `dotnet`, `python`, `typescript`, `bash`, `powershell`, `markdown`, `go`, `yaml`, and `cross-cutting` (each resolved per the ladder); any with matching files is exposed as a filter. Aliases: `py` → `python`; `ts`/`node` → `typescript`; `shell` → `bash`; `ps`/`pwsh` → `powershell`; `md` → `markdown`; `golang` → `go`; `xc`/`text` → `cross-cutting`. Literal `all` runs every applicable ecosystem.
**Mode flag:**
@@ -100,6 +100,7 @@ Per-project walking (ecosystems with `project-discovery`):
- python: walk each `pyproject.toml` directory and run check/fix from there
- typescript: walk each `package.json` directory and run check/fix from there
+- go: walk each `go.mod` directory and run check/fix from there (a root `./...` invocation stops at a nested module boundary — see `/toolchain:check`'s per-ecosystem context file)
Tool presence: verify tools on `PATH` before each ecosystem; report `skip` with `install-hint` when missing.