diff --git a/docs/conventions/hook-budget/README.md b/docs/conventions/hook-budget/README.md index c7b0f917d2..d0d99e5fdf 100644 --- a/docs/conventions/hook-budget/README.md +++ b/docs/conventions/hook-budget/README.md @@ -133,9 +133,9 @@ Every shipped hook row, except the shell-form rows named under "Scope", is exec unresolvable, the launcher exits 1: a non-blocking hook error and not a guard block, and the guard script does not run (the header of [`lib/exec-bash.mjs`](../../../lib/exec-bash.mjs)). -- **Scope.** Three rows stay shell form so they can report a missing `node`: the `SessionStart` notice rows in `guardrails` and `disk-hygiene`, and the `hook-failure-audit` Stop row in `harness-ops`. - Neither check script inspects a shell-form row; each of the three plugins' own hook tests pins its - row's shell form, so a sweep back to `node` fails that test. A plugin hook config carries no +- **Scope.** The `SessionStart` node-notice row of every hook plugin (the [prerequisites convention](../prerequisites/README.md#hook-notices)) and the `hook-failure-audit` Stop row in `harness-ops` stay shell form so they can report a missing `node`. + Neither check script inspects a shell-form row; `scripts/node-notice-rows.test.sh` pins the node-notice + rows and the hook test of `harness-ops` pins its Stop row, so a sweep back to `node` fails them. A plugin hook config carries no `${user_config.*}` token (the [philosophy Hooks row](../../plugin-philosophy.md#component-stances)), so no `userConfig` rule requires exec form and exec form fleet-wide is this sweep's choice. - **Measurement.** The reference figures above (Windows, 2026-07-31 and 2026-09-02) were taken before diff --git a/docs/conventions/hook-observability/README.md b/docs/conventions/hook-observability/README.md index 32504d2e89..f4d209ca97 100644 --- a/docs/conventions/hook-observability/README.md +++ b/docs/conventions/hook-observability/README.md @@ -136,7 +136,7 @@ count, or the disclosure becomes the noise problem it was meant to prevent. section changes how it treats the field. **Repeat-notice discipline.** A missing-prerequisite notice behind a broad matcher (every -`Write|Edit`, every `Bash` call) must not repeat on every invocation. Use `hook::require_jq` +`Write|Edit`, every `Bash` call) must not repeat on every invocation. Use `hook::require jq` (wraps `hook::notice_once` + `hook::emit_skip_notice`) for a missing-`jq` gate, or pair `hook::notice_once` with `hook::emit_skip_notice` directly for a non-`jq` prerequisite. A raw, unguarded `hook::emit_skip_notice` call on a broad-matcher hook is a conformance defect. The latch @@ -327,7 +327,7 @@ different event. Fleet audits check, per wired producer hook: - Every `command`-type handler in its `hooks.json` declares a `statusMessage`. -- Every missing-prerequisite skip path emits a `systemMessage` (via `hook::require_jq` or +- Every missing-prerequisite skip path emits a `systemMessage` (via `hook::require jq` or `hook::notice_once` + `hook::emit_skip_notice`), gated so it fires once per session and agent (renewed every eighth skip) on a broad matcher. - Any `systemMessage` that is neither a prerequisite-skip notice nor a content-mutation notice diff --git a/docs/conventions/prerequisites/README.md b/docs/conventions/prerequisites/README.md index c373a422e7..ec00d3a9c6 100644 --- a/docs/conventions/prerequisites/README.md +++ b/docs/conventions/prerequisites/README.md @@ -132,6 +132,24 @@ arguments and exit code when `node` is on `PATH`. Without `node` they print this prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again. ``` +## Hook notices + +Two notices cover a hook's dependencies. Both name `/:check`, never `/:setup`, +because `setup` is manual-only (the +[philosophy](../../plugin-philosophy.md#setup-is-explicit-and-repeatable) explains the split). A +plugin whose `check` skill already means something else (`instruction-placement`, `skill-quality` +and `toolchain`) ships `check-prerequisites` and names that. No hook installs anything. + +| Notice | Fires | How | +| --- | --- | --- | +| `node` is missing | `SessionStart`, once per session across every plugin | Each hook plugin carries one shell-form `SessionStart` row that runs `lib/prerequisites.sh node-notice`, then `lib/prerequisites.ps1 node-notice`. bash takes the first and leaves at `${BASH_VERSION:+exit}`; PowerShell, the default shell on Windows without Git Bash, has no `sh`, skips to the second. Both stubs share a latch keyed by session id in the temp directory, so a session with several hook plugins sees one notice. A plugin's `_enabled` kill switch, passed as the last argument, silences its row. | +| Another hook dependency is missing | `SessionStart` for an entry whose `for` names a hook, via `probe`; and at the point of use | `hook::require ` in `lib/hook-utils.sh`. It fails open: when `` is not on `PATH` it prints one skip notice per session and agent and exits 0. The text comes from the plugin's declared entry: `degrade`, the `docs` install link and `check`. An entry that names only a skill never notifies at session start. | + +`hook::require` reads an entry with bash alone, from its `id` to the next one, so write `id` first in every entry. + +`hook::require_jq_blocking` stays separate. It denies the call, so it prints its reason every time +and does not latch. + ## The CI gate `node scripts/check-declared-prerequisites.mjs` runs in CI and checks three things: diff --git a/docs/plugin-philosophy.md b/docs/plugin-philosophy.md index 79e210b0e8..3fc785efe2 100644 --- a/docs/plugin-philosophy.md +++ b/docs/plugin-philosophy.md @@ -312,7 +312,7 @@ results, not omissions; the trigger, never the date, is what obliges re-deriving | [`commands/`](https://code.claude.com/docs/en/plugins/components#commands) | Prohibited | Superseded by skills upstream; every new capability goes in `skills/`. Existing flat commands migrate to skill directories. | 2026-07-17 | | [Agents](https://code.claude.com/docs/en/plugins/components#frontmatter-fields-in-plugin-agents) | Adopt on need | Plugin agents do not support `hooks`, `mcpServers`, or `permissionMode` (security restriction). Design within that limit rather than working around it. | 2026-07-17 | | [Workflows](https://code.claude.com/docs/en/workflows#distribute-a-workflow-in-a-plugin) | Adopt on need | Native and not experimental: a script in `workflows/`, or wherever the `workflows` manifest field points (that field replaces the default scan), runs as a plugin-namespaced `/plugin:name` command. Availability, not maturity, is the constraint: plan gating and the user and organization off switches are upstream's, listed at [Turn workflows off](https://code.claude.com/docs/en/workflows#turn-workflows-off) (recheck when a switch or plan gate changes); so, as with `bin/`, never make a workflow the only path to a capability. Not "Wait": the [deferred workflow engines](adr/0020-defer-three-medley-surfaces-with-explicit-recheck-triggers.md) are a named candidate carrying a live trigger, so the gap is identified rather than hypothetical. One ships: review's `fanout-sweep`, which `/review:fanout run-everything` launches and which keeps a main-thread fallback. Scripts follow the [workflow authoring convention](#workflow-authoring-convention). | 2026-10-02 | -| [Hooks](https://code.claude.com/docs/en/hooks) | Adopt on need | A plugin hook config carries no `${user_config.*}` token: an unset defaulted token drops the whole hook entry, so `scripts/check-hook-userconfig-argv.sh` rejects it. Read the `CLAUDE_PLUGIN_OPTION_` mirror instead ([hook-config-delivery](conventions/hook-config-delivery/)). On Windows, exec form launches an executable file (a `.exe`, for example) directly with the `args` array and no shell, so a shebang script or a `.cmd`/`.bat` shim is not a `command`, and neither is a bare `bash`, `sh`, `python`, or `python3` (a failed launch is non-blocking, so a guard then enforces nothing). Shell form with `"shell": "bash"` stays legal; every plugin hook row uses exec form, `"command": "node"` with the script path in `args`, except the guardrails and disk-hygiene SessionStart node notice rows and the harness-ops hook-failure-audit Stop row, which run in shell form with `"shell": "bash"` because they must work when `node` is missing. `node` must be on `PATH`, and we do not assume a Claude Code install brings it (pointers: [Exec form and shell form](https://code.claude.com/docs/en/hooks#exec-form-and-shell-form), [Install with npm](https://code.claude.com/docs/en/setup#install-with-npm)). We treat a hook that cannot start as a guard that enforced nothing, with the transcript notice as the only signal (pointer: [Other exit codes](https://code.claude.com/docs/en/hooks#other-exit-codes)). `scripts/check-hook-exec-form.sh` rejects a bare name other than `node`. `scripts/check-exec-form-windows-probe.sh` rejects a script path used as `command`; its non-Windows skip does not authorize converting `.sh` rows. The record is [Windows exec-form probe](#windows-exec-form-probe). Hooks modules ("mods"), the in-process TypeScript hook form, are deferred: see the mods row under [Recorded gate runs](#recorded-gate-runs) and [ADR 0035](adr/0035-defer-claude-code-mods-with-five-go-criteria.md). | 2026-10-02 | +| [Hooks](https://code.claude.com/docs/en/hooks) | Adopt on need | A plugin hook config carries no `${user_config.*}` token: an unset defaulted token drops the whole hook entry, so `scripts/check-hook-userconfig-argv.sh` rejects it. Read the `CLAUDE_PLUGIN_OPTION_` mirror instead ([hook-config-delivery](conventions/hook-config-delivery/)). On Windows, exec form launches an executable file (a `.exe`, for example) directly with the `args` array and no shell, so a shebang script or a `.cmd`/`.bat` shim is not a `command`, and neither is a bare `bash`, `sh`, `python`, or `python3` (a failed launch is non-blocking, so a guard then enforces nothing). Shell form with `"shell": "bash"` stays legal; every plugin hook row uses exec form, `"command": "node"` with the script path in `args`, except the node-notice SessionStart row every hook plugin carries (shell form with no `shell` field, so it runs under bash, or under PowerShell on Windows without Git Bash; [prerequisites convention](conventions/prerequisites/README.md#hook-notices)) and the harness-ops hook-failure-audit Stop row (shell form with `"shell": "bash"`), because they must work when `node` is missing. `node` must be on `PATH`, and we do not assume a Claude Code install brings it (pointers: [Exec form and shell form](https://code.claude.com/docs/en/hooks#exec-form-and-shell-form), [Install with npm](https://code.claude.com/docs/en/setup#install-with-npm)). We treat a hook that cannot start as a guard that enforced nothing, with the transcript notice as the only signal (pointer: [Other exit codes](https://code.claude.com/docs/en/hooks#other-exit-codes)). `scripts/check-hook-exec-form.sh` rejects a bare name other than `node`. `scripts/check-exec-form-windows-probe.sh` rejects a script path used as `command`; its non-Windows skip does not authorize converting `.sh` rows. The record is [Windows exec-form probe](#windows-exec-form-probe). Hooks modules ("mods"), the in-process TypeScript hook form, are deferred: see the mods row under [Recorded gate runs](#recorded-gate-runs) and [ADR 0035](adr/0035-defer-claude-code-mods-with-five-go-criteria.md). | 2026-10-02 | | [MCP servers](https://code.claude.com/docs/en/mcp) | Adopt on need | Clears the plugin-acceptance security review for egress and trust delegation. Also the only component type that can cost a consumer their prompt cache: every other kind only appends to the request, while enabling or disabling a plugin that provides an MCP server forces a full re-read whenever the server's tools load into the prefix instead of being deferred by tool search (pointer: [actions that invalidate the cache](https://code.claude.com/docs/en/prompt-caching#actions-that-invalidate-the-cache)). | 2026-08-10 | | [LSP servers](https://code.claude.com/docs/en/plugins/components#lsp-servers) | Adopt on need | Consumer must have the language-server binary; declare the prerequisite per the failure-behavior rules. | 2026-07-17 | | [Output styles](https://code.claude.com/docs/en/plugins/components#themes-and-output-styles) | Adopt on need | No additional constraints. | 2026-07-17 | diff --git a/docs/skill-cheat-sheet.md b/docs/skill-cheat-sheet.md index 31dfcbfb75..895f879fad 100644 --- a/docs/skill-cheat-sheet.md +++ b/docs/skill-cheat-sheet.md @@ -261,6 +261,7 @@ owned by [docs/catalog-taxonomy.md](catalog-taxonomy.md). | [`/improvement:find`](../plugins/improvement/skills/find/SKILL.md) | `improvement` | Rank evidence-cited improvement candidates across dimensions; execution goes to the pipeline | | [`/instruction-placement:audit`](../plugins/instruction-placement/skills/audit/SKILL.md) | `instruction-placement` | Find instruction content on the wrong surface and propose validated destinations | | [`/instruction-placement:check`](../plugins/instruction-placement/skills/check/SKILL.md) | `instruction-placement` | Gate that every path-scoped rule glob resolves and the rules index is current | +| [`/instruction-placement:check-prerequisites`](../plugins/instruction-placement/skills/check-prerequisites/SKILL.md) | `instruction-placement` | Report whether node and jq resolve for instruction-placement. Never installs. | | [`/instruction-placement:delta`](../plugins/instruction-placement/skills/delta/SKILL.md) | `instruction-placement` | Report only what moved since the last placement audit | | [`/markdown-format:check`](../plugins/markdown-format/skills/check/SKILL.md) | `markdown-format` | Report whether markdownlint-cli2 and node are installed. Never installs. | | [`/multi-agent:audit-defaults`](../plugins/multi-agent/skills/audit-defaults/SKILL.md) | `multi-agent` | Recheck the routing defaults against their upstream sources | @@ -281,9 +282,11 @@ owned by [docs/catalog-taxonomy.md](catalog-taxonomy.md). | [`/session-flow:check`](../plugins/session-flow/skills/check/SKILL.md) | `session-flow` | Report whether node and jq resolve for the session-flow observer hook. Never installs. | | [`/session-flow:show-options`](../plugins/session-flow/skills/show-options/SKILL.md) | `session-flow` | Lay out the skills that fit this moment as a ranked, nothing-hidden menu | | [`/session-flow:workflow`](../plugins/session-flow/skills/workflow/SKILL.md) | `session-flow` | Navigate the staged dev workflow and suggest the next stage | +| [`/skill-quality:check-prerequisites`](../plugins/skill-quality/skills/check-prerequisites/SKILL.md) | `skill-quality` | Report whether the tools skill-quality declares resolve. Never installs. | | [`/source-control:check`](../plugins/source-control/skills/check/SKILL.md) | `source-control` | Report whether node and jq resolve for the source-control hooks. Never installs. | | [`/speech:check`](../plugins/speech/skills/check/SKILL.md) | `speech` | Report each missing speech prerequisite. Never installs. | | [`/testing:check`](../plugins/testing/skills/check/SKILL.md) | `testing` | Report whether node and jq resolve for the testing hooks. Never installs. | +| [`/toolchain:check-prerequisites`](../plugins/toolchain/skills/check-prerequisites/SKILL.md) | `toolchain` | Report whether the tools toolchain declares resolve. Never installs. | | [`/typos-format:check`](../plugins/typos-format/skills/check/SKILL.md) | `typos-format` | Report whether typos and node are installed. Never installs. | | [`/visualization:visualize`](../plugins/visualization/skills/visualize/SKILL.md) | `visualization` | Pick the best visual form for what is in the conversation and render it | | [`/wizard:generate`](../plugins/wizard/skills/generate/SKILL.md) | `wizard` | Author a hardened interactive bash wizard for human-only setup, credential, and cutover steps | diff --git a/lib/hook-utils.sh b/lib/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/lib/hook-utils.sh +++ b/lib/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/lib/hook-utils.test.sh b/lib/hook-utils.test.sh index 33e991eb2c..619c59e347 100755 --- a/lib/hook-utils.test.sh +++ b/lib/hook-utils.test.sh @@ -1471,16 +1471,16 @@ else ok "raw_file_path: absent file_path returns 1" fi -# --- Test 17: hook::require_jq — gate behavior -------------------------------- +# --- Test 17: hook::require — gate behavior -------------------------------- # jq present → returns 0, no output, no exit. out17=$( ( - hook::require_jq PostToolUse tp '{"session_id":"s"}' + hook::require jq PostToolUse tp '{"session_id":"s"}' echo "alive" ) 2>/dev/null) if [[ "$out17" == "alive" ]]; then - ok "require_jq: jq present → pass-through" + ok "require: jq present → pass-through" else - fail "require_jq: jq present misbehaved: $out17" + fail "require: jq present misbehaved: $out17" fi # jq absent → notice on first run, exit 0; suppressed on second. Simulate the # REAL missing-jq shape (Git Bash without jq): a stub PATH that still carries @@ -1505,7 +1505,7 @@ run17() { CLAUDE_PLUGIN_DATA="$DATA17" "$BASH" -c ' PATH="'"$FAKEBIN17"'" source "'"$HOOK_DIR"'/hook-utils.sh" - hook::require_jq PostToolUse tp "{\"session_id\":\"cccc-3333\"}" + hook::require jq PostToolUse tp "{\"session_id\":\"cccc-3333\"}" echo "unreachable" ' 2>/dev/null } @@ -1514,17 +1514,75 @@ rc_first=$? second17=$(run17) rc_second=$? if [[ $rc_first -eq 0 && "$first17" == *'"systemMessage"'* && "$first17" != *unreachable* ]]; then - ok "require_jq: jq absent → visible notice + exit 0" + ok "require: jq absent → visible notice + exit 0" else - fail "require_jq: first run rc=$rc_first out=$first17" + fail "require: first run rc=$rc_first out=$first17" fi if [[ $rc_second -eq 0 && "$second17" != *'"systemMessage"'* && "$second17" != *unreachable* ]]; then - ok "require_jq: second run same session → silent exit 0" + ok "require: second run same session → silent exit 0" else - fail "require_jq: second run rc=$rc_second out=$second17" + fail "require: second run rc=$rc_second out=$second17" fi rm -rf "$DATA17" "$FAKEBIN17" +# hook::require builds its notice from the declared prerequisites.json entry, and +# falls back to a /:check derived from the plugin root when there is none. +FAKEBIN17="$(make_stub_bin)" +ROOT17="$(mktemp -d)/cache/market/demo-plug/1.2.3" +mkdir -p "$ROOT17" +cat >"$ROOT17/prerequisites.json" <<'JSON' +{ + "requires": [ + { + "id": "node", + "kind": "runtime", + "degrade": "Without node, nothing runs.", + "install": { "docs": "https://example.invalid/node" }, + "check": "/demo-plug:check-node" + }, + { + "id": "jq", + "kind": "cli", + "need": "required", + "for": ["hook:demo.sh"], + "degrade": "Without jq, the \"demo\" hook is skipped.", + "install": { "docs": "https://example.invalid/jq", "brew": "jq" }, + "check": "/demo-plug:check" + } + ] +} +JSON +require17() { + local root="$1" id="${2:-jq}" + CLAUDE_PLUGIN_ROOT="$root" CLAUDE_PLUGIN_DATA="$(mktemp -d "$WORK/data17r.XXXXXX")" "$BASH" -c ' + PATH="'"$FAKEBIN17"'" + source "'"$HOOK_DIR"'/hook-utils.sh" + hook::require '"$id"' PostToolUse tp "{\"session_id\":\"dddd-4444\"}" + ' 2>/dev/null +} +declared17=$(require17 "$ROOT17") +if [[ "$declared17" == *'Without jq, the \"demo\" hook is skipped.'* && "$declared17" == *'Install: https://example.invalid/jq.'* && + "$declared17" == *'Run /demo-plug:check to verify. It does not install.'* && "$declared17" != *'/demo-plug:check-node'* ]]; then + ok "require: notice carries the declared degrade text, first install doc link and check command" +else + fail "require: declared entry notice: $declared17" +fi +rm "$ROOT17/prerequisites.json" +generic17=$(require17 "$ROOT17") +if [[ "$generic17" == *'tp: jq not found on PATH'* && "$generic17" == *'Run /demo-plug:check to verify.'* && "$generic17" != *harness-ops* ]]; then + ok "require: no declared entry → generic text and /:check from the plugin root" +else + fail "require: generic notice: $generic17" +fi +mkdir -p "$ROOT17/skills/check-prerequisites" +colliding17=$(require17 "$ROOT17") +if [[ "$colliding17" == *'Run /demo-plug:check-prerequisites to verify.'* ]]; then + ok "require: a plugin shipping check-prerequisites names that skill" +else + fail "require: check-prerequisites notice: $colliding17" +fi +rm -rf "$ROOT17" "$FAKEBIN17" + # --- Test 17b: hook::require_jq_blocking — fail-closed gate (#2146) ----------- out17b=$( ( hook::require_jq_blocking test-hook block_test_enabled @@ -4509,7 +4567,7 @@ else fi # The jq gate, and the pre-filter that must run BEFORE it. Same missing-jq -# shape the require_jq cases above use: a stub PATH carrying the coreutils +# shape the require cases above use: a stub PATH carrying the coreutils # notice_once needs and no jq. BG_NOJQ="$(make_stub_bin)" BG_DATA="$(mktemp -d)" diff --git a/lib/prerequisites.ps1 b/lib/prerequisites.ps1 index cf3b86546b..2bab878cbe 100644 --- a/lib/prerequisites.ps1 +++ b/lib/prerequisites.ps1 @@ -3,7 +3,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/lib/prerequisites.sh b/lib/prerequisites.sh index 7bbec2d867..76e2f96073 100755 --- a/lib/prerequisites.sh +++ b/lib/prerequisites.sh @@ -4,6 +4,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/lib/prerequisites.test.sh b/lib/prerequisites.test.sh index ddb373ec67..b2e2e0eba7 100755 --- a/lib/prerequisites.test.sh +++ b/lib/prerequisites.test.sh @@ -66,6 +66,97 @@ else printf 'NOTE: pwsh is not on PATH; the pwsh stub cases did not run.\n' fi +# node-notice: the SessionStart mode that must work with node absent. The stub PATH +# carries the coreutils the sh stub calls and no node. +TOOLS="$WORK/tools-path" +mkdir -p "$TOOLS" "$WORK/tmp" "$WORK/tmp-ps" +for t in sed tr find mkdir cat rm; do ln -s "$(command -v "$t")" "$TOOLS/$t"; done +notice_sh() { # + local payload="$1" + shift + printf '%s' "$payload" | PATH="$TOOLS" TMPDIR="$WORK/tmp" "$SH" "$LIB/prerequisites.sh" node-notice "$@" +} +notice_shape() { # reads a notice on stdin; prints ok when both channels name and its check skill + node -e 'const j=JSON.parse(require("fs").readFileSync(0,"utf8"));const p=process.argv[1];process.stdout.write(j.systemMessage.startsWith(p+": node is not on PATH")&&j.systemMessage.includes("Run /"+p+":check to verify")&&j.hookSpecificOutput.hookEventName==="SessionStart"&&j.hookSpecificOutput.additionalContext.includes("/"+p+":check")?"ok":"bad")' "$1" +} +s1='{"session_id":"sess-1","hook_event_name":"SessionStart"}' + +out="$(notice_sh "$s1" /bash-format:check)" +if [[ "$(printf '%s' "$out" | notice_shape bash-format)" == ok ]]; then + pass "sh node-notice: node absent prints one SessionStart notice on both channels naming the check skill" +else + fail "sh node-notice: node absent gave $out" +fi +out="$(notice_sh "$s1" /guardrails:check)" +if [[ -z "$out" ]]; then + pass "sh node-notice: a second plugin in the same session stays silent" +else + fail "sh node-notice: second plugin printed $out" +fi +out="$(notice_sh '{"session_id":"sess-2"}' /guardrails:check)" +if [[ "$(printf '%s' "$out" | notice_shape guardrails)" == ok ]]; then + pass "sh node-notice: a new session gets its own notice" +else + fail "sh node-notice: new session gave $out" +fi +out="$(notice_sh '{"session_id":"sess-3"}' /bash-format:check BASH_FORMAT_ENABLED)" +if [[ "$out" == *'node is not on PATH'* ]]; then + pass "sh node-notice: an unset kill switch leaves the notice on" +else + fail "sh node-notice: unset kill switch gave $out" +fi +out="$(CLAUDE_PLUGIN_OPTION_BASH_FORMAT_ENABLED=false notice_sh '{"session_id":"sess-4"}' /bash-format:check BASH_FORMAT_ENABLED)" +if [[ -z "$out" ]]; then + pass "sh node-notice: a plugin kill switch set to false silences the notice" +else + fail "sh node-notice: kill switch false gave $out" +fi +a="$(notice_sh '{}' /bash-format:check)" +b="$(notice_sh '{}' /guardrails:check)" +if [[ -n "$a" && -n "$b" ]]; then + pass "sh node-notice: with no session id every plugin still notifies" +else + fail "sh node-notice: no session id gave a=$a b=$b" +fi +out="$(printf '%s' '{"session_id":"sess-5"}' | TMPDIR="$WORK/tmp" "$SH" "$LIB/prerequisites.sh" node-notice /bash-format:check)" +if [[ -z "$out" ]]; then + pass "sh node-notice: node present prints nothing" +else + fail "sh node-notice: node present gave $out" +fi + +if [[ -n "${PWSH:-}" ]]; then + notice_ps() { # + local payload="$1" + shift + printf '%s' "$payload" | PATH="$EMPTY" TMPDIR="$WORK/tmp-ps" "$PWSH" -NoProfile -NonInteractive -File "$LIB/prerequisites.ps1" node-notice "$@" + } + out="$(notice_ps "$s1" /bash-format:check)" + if [[ "$(printf '%s' "$out" | notice_shape bash-format)" == ok ]]; then + pass "pwsh node-notice: node absent prints one SessionStart notice on both channels" + else + fail "pwsh node-notice: node absent gave $out" + fi + out="$(notice_ps "$s1" /guardrails:check)" + if [[ -z "$out" ]]; then + pass "pwsh node-notice: a second plugin in the same session stays silent" + else + fail "pwsh node-notice: second plugin printed $out" + fi + out="$(CLAUDE_PLUGIN_OPTION_BASH_FORMAT_ENABLED=false notice_ps '{"session_id":"sess-4"}' /bash-format:check BASH_FORMAT_ENABLED)" + if [[ -z "$out" ]]; then + pass "pwsh node-notice: a plugin kill switch set to false silences the notice" + else + fail "pwsh node-notice: kill switch false gave $out" + fi + out="$(printf '%s' "$s1" | "$PWSH" -NoProfile -NonInteractive -File "$LIB/prerequisites.ps1" node-notice /bash-format:check)" + if [[ -z "$out" ]]; then + pass "pwsh node-notice: node present prints nothing" + else + fail "pwsh node-notice: node present gave $out" + fi +fi + if [[ "$FAILED" -gt 0 ]]; then printf '%d stub case(s) failed\n' "$FAILED" >&2 exit 1 diff --git a/plugins/actionlint/.claude-plugin/plugin.json b/plugins/actionlint/.claude-plugin/plugin.json index fb82c20b6e..e06705e48d 100644 --- a/plugins/actionlint/.claude-plugin/plugin.json +++ b/plugins/actionlint/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "actionlint", - "version": "0.11.16", + "version": "0.12.0", "description": "Lint GitHub Actions workflow files on edit via actionlint, surfacing findings as advisory context.", "author": { "name": "Melodic Software", diff --git a/plugins/actionlint/CHANGELOG.md b/plugins/actionlint/CHANGELOG.md index b31a301886..1441f180e5 100644 --- a/plugins/actionlint/CHANGELOG.md +++ b/plugins/actionlint/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `actionlint` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.12.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/actionlint:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.11.16] - 2026-10-02 ### Changed diff --git a/plugins/actionlint/hooks/hook-utils.sh b/plugins/actionlint/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/actionlint/hooks/hook-utils.sh +++ b/plugins/actionlint/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/actionlint/hooks/hooks.json b/plugins/actionlint/hooks/hooks.json index 8f44a79b88..fda9a90b16 100644 --- a/plugins/actionlint/hooks/hooks.json +++ b/plugins/actionlint/hooks/hooks.json @@ -47,6 +47,16 @@ "statusMessage": "Checking actionlint..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /actionlint:check ACTIONLINT_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /actionlint:check ACTIONLINT_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/actionlint/lib/prerequisites.ps1 b/plugins/actionlint/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/actionlint/lib/prerequisites.ps1 +++ b/plugins/actionlint/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/actionlint/lib/prerequisites.sh b/plugins/actionlint/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/actionlint/lib/prerequisites.sh +++ b/plugins/actionlint/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/ai-briefing/.claude-plugin/plugin.json b/plugins/ai-briefing/.claude-plugin/plugin.json index b3d872e2a1..0107cf06c4 100644 --- a/plugins/ai-briefing/.claude-plugin/plugin.json +++ b/plugins/ai-briefing/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-briefing", - "version": "0.8.0", + "version": "0.8.1", "description": "Build source-backed AI-industry briefings from official vendor publications, configured RSS/Atom feeds, GitHub releases, reputable secondary reporting, and user-supplied URLs. Deduplicate, rank, and present results as markdown or optional HTML/PPTX decks, with repository-owned profile, audience, and brand configuration. Automated X/Twitter collection is disabled; Playwright is used only for deterministic local rendering.", "author": { "name": "Melodic Software", diff --git a/plugins/ai-briefing/CHANGELOG.md b/plugins/ai-briefing/CHANGELOG.md index a50f9a9cde..bb20d8a3d2 100644 --- a/plugins/ai-briefing/CHANGELOG.md +++ b/plugins/ai-briefing/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `ai-briefing` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.8.0] - 2026-10-02 ### Added diff --git a/plugins/ai-briefing/lib/prerequisites.ps1 b/plugins/ai-briefing/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/ai-briefing/lib/prerequisites.ps1 +++ b/plugins/ai-briefing/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/ai-briefing/lib/prerequisites.sh b/plugins/ai-briefing/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/ai-briefing/lib/prerequisites.sh +++ b/plugins/ai-briefing/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/ai-slop/.claude-plugin/plugin.json b/plugins/ai-slop/.claude-plugin/plugin.json index 5d3952ed88..0f700cb99b 100644 --- a/plugins/ai-slop/.claude-plugin/plugin.json +++ b/plugins/ai-slop/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ai-slop", - "version": "0.13.1", + "version": "0.13.2", "description": "Detects and removes AI-writing tells (slop) in tracked markdown prose: em dashes, emoji formatting, AI vocabulary, negative parallelisms, chatbot phrases, filler, stacked hedging, citation artifacts, and model-era phrases, from a catalog based on Wikipedia's Signs of AI writing plus an evidence-graded model-vocabulary inventory. Read-only audit by default (deterministic detector plus judgment rubric); an explicit fix action rewrites findings behind a semantic-diff guard.", "author": { "name": "Melodic Software", diff --git a/plugins/ai-slop/CHANGELOG.md b/plugins/ai-slop/CHANGELOG.md index b035387d64..fec829f9b4 100644 --- a/plugins/ai-slop/CHANGELOG.md +++ b/plugins/ai-slop/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.13.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.13.1] - 2026-10-02 ### Changed diff --git a/plugins/ai-slop/lib/prerequisites.ps1 b/plugins/ai-slop/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/ai-slop/lib/prerequisites.ps1 +++ b/plugins/ai-slop/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/ai-slop/lib/prerequisites.sh b/plugins/ai-slop/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/ai-slop/lib/prerequisites.sh +++ b/plugins/ai-slop/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/animation/.claude-plugin/plugin.json b/plugins/animation/.claude-plugin/plugin.json index db085829bf..b644afa983 100644 --- a/plugins/animation/.claude-plugin/plugin.json +++ b/plugins/animation/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "animation", - "version": "0.3.1", + "version": "0.3.2", "description": "Hand-drawn-style 2D animation as code, rendered in headless Chromium by an ink brush engine. rotoscope copies a reference clip drawing by drawing: trace to vector paths, render, measure against the source (XOR, SSIM), and fit per-shot brush overrides. learn-style measures a clip into a style pack (ships woodcut-ink) and checks films against it. produce turns a brief into approved boards, a shot list, frames, and a delivered file. setup checks ffmpeg, Node, Chromium, and Python.", "author": { "name": "Melodic Software", diff --git a/plugins/animation/CHANGELOG.md b/plugins/animation/CHANGELOG.md index 637a9e761b..c9e12bac5b 100644 --- a/plugins/animation/CHANGELOG.md +++ b/plugins/animation/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `animation` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.2] - 2026-10-03 + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.3.1] - 2026-10-02 ### Changed diff --git a/plugins/animation/hooks/hook-utils.sh b/plugins/animation/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/animation/hooks/hook-utils.sh +++ b/plugins/animation/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/animation/lib/prerequisites.ps1 b/plugins/animation/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/animation/lib/prerequisites.ps1 +++ b/plugins/animation/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/animation/lib/prerequisites.sh b/plugins/animation/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/animation/lib/prerequisites.sh +++ b/plugins/animation/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/architecture/.claude-plugin/plugin.json b/plugins/architecture/.claude-plugin/plugin.json index 50b05d7ad8..b09b6d4930 100644 --- a/plugins/architecture/.claude-plugin/plugin.json +++ b/plugins/architecture/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "architecture", - "version": "0.20.0", + "version": "0.20.1", "description": "improve uses Ousterhout's deep-module lens to find shallow modules, seam leaks, and locality gaps, reports them as HTML, and interviews the chosen one. map-landscape charts a C4 system landscape and portfolio table with drift checks. map-dependencies, map-components, map-containers, map-context, map-flow, map-events, map-data, and map-deployment draw dependency, C4, sequence, event, data, and deployment views from committed files. record-decision writes an ADR in the repo's convention.", "author": { "name": "Melodic Software", diff --git a/plugins/architecture/CHANGELOG.md b/plugins/architecture/CHANGELOG.md index d0c0b5ca5a..4277e1946c 100644 --- a/plugins/architecture/CHANGELOG.md +++ b/plugins/architecture/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `architecture` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.20.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.20.0] - 2026-10-03 ### Changed diff --git a/plugins/architecture/lib/prerequisites.ps1 b/plugins/architecture/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/architecture/lib/prerequisites.ps1 +++ b/plugins/architecture/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/architecture/lib/prerequisites.sh b/plugins/architecture/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/architecture/lib/prerequisites.sh +++ b/plugins/architecture/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/attribution/.claude-plugin/plugin.json b/plugins/attribution/.claude-plugin/plugin.json index c605ec8d0d..3a90c23470 100644 --- a/plugins/attribution/.claude-plugin/plugin.json +++ b/plugins/attribution/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "attribution", - "version": "0.10.1", + "version": "0.10.2", "description": "Finds prose in tracked markdown that restates content an external source owns (vendor docs, blogs, articles) without attribution, confirms the source, and converts the copy into a pointer, a citation, or a dated stamped record. Documentation attribution, not software supply chain. Scripts do only mechanical work; judgment is LLM work. Read-only audit by default; explicit fix and sweep actions apply changes behind a semantic-diff guard and live pointer checks.", "author": { "name": "Melodic Software", diff --git a/plugins/attribution/CHANGELOG.md b/plugins/attribution/CHANGELOG.md index 3c90cd3946..4028cb0a9f 100644 --- a/plugins/attribution/CHANGELOG.md +++ b/plugins/attribution/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [0.10.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.10.1] - 2026-10-02 ### Changed diff --git a/plugins/attribution/lib/prerequisites.ps1 b/plugins/attribution/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/attribution/lib/prerequisites.ps1 +++ b/plugins/attribution/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/attribution/lib/prerequisites.sh b/plugins/attribution/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/attribution/lib/prerequisites.sh +++ b/plugins/attribution/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/autonomy/.claude-plugin/plugin.json b/plugins/autonomy/.claude-plugin/plugin.json index 7d4b3bf800..048ac11e38 100644 --- a/plugins/autonomy/.claude-plugin/plugin.json +++ b/plugins/autonomy/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "autonomy", - "version": "0.25.11", + "version": "0.26.0", "description": "Contracts for governed autonomous agent operation: role topology, binding seam, telemetry, return accounting, trigger dispatch, a per-work-class guardrail matrix, a standing-routine catalog, and a runner charter. A guided setup skill writes an org's binding, wires OTLP telemetry (file default), human-attested return capture, and signal adapters behind one governed dispatch entrypoint, binds the guardrail matrix to isolation substrates after a live probe, and schedules routines.", "author": { "name": "Melodic Software", diff --git a/plugins/autonomy/CHANGELOG.md b/plugins/autonomy/CHANGELOG.md index 54770261a5..b120faf1d3 100644 --- a/plugins/autonomy/CHANGELOG.md +++ b/plugins/autonomy/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `autonomy` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.26.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/autonomy:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. +- Hooks call `hook::require jq` where they called `hook::require_jq`. + ## [0.25.11] - 2026-10-02 ### Changed diff --git a/plugins/autonomy/hooks/hook-utils.sh b/plugins/autonomy/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/autonomy/hooks/hook-utils.sh +++ b/plugins/autonomy/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/autonomy/hooks/hooks.json b/plugins/autonomy/hooks/hooks.json index b2d81651c3..480a3298bf 100644 --- a/plugins/autonomy/hooks/hooks.json +++ b/plugins/autonomy/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Gates the Stop event so a Ralph-loop lane continues or halts on its own recorded state.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /autonomy:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /autonomy:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "Stop": [ { "hooks": [ diff --git a/plugins/autonomy/hooks/lane-stop-gate.sh b/plugins/autonomy/hooks/lane-stop-gate.sh index 994a535bdb..e4a29f2e5f 100755 --- a/plugins/autonomy/hooks/lane-stop-gate.sh +++ b/plugins/autonomy/hooks/lane-stop-gate.sh @@ -119,7 +119,7 @@ esac # that routes into evaluation and nothing more, the same forcing power the # repo's own `env` block already has over the presence tests. # Nothing here is payload-derived, so it MUST stay above the buffer — and -# everything payload-derived (hook::require_jq, EVENT, SESSION_ID, and the +# everything payload-derived (hook::require jq, EVENT, SESSION_ID, and the # SubagentStop-versus-Stop discrimination) MUST stay below it (#2852). gate_maybe_configured() { [[ -n "${CLAUDE_PLUGIN_OPTION_LANE_STOP_GATE_ARM_ID:-}" ]] && return 0 @@ -208,7 +208,7 @@ hook::buffer_stdin_to INPUT || exit 0 # jq parses the payload and the trusted config. Absent → visible once per session and agent # notice, then allow the stop (fail-open). Stop supports additionalContext, so # the notice reaches both the agent and the user. -hook::require_jq "Stop" "autonomy-lane-stop-gate" "$INPUT" +hook::require jq "Stop" "autonomy-lane-stop-gate" "$INPUT" # Every payload field the gate reads, in ONE jq pass: five `printf | jq | tr` # pipelines used to read the same buffer one field at a time. EVENT, SESSION_ID, diff --git a/plugins/autonomy/lib/prerequisites.ps1 b/plugins/autonomy/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/autonomy/lib/prerequisites.ps1 +++ b/plugins/autonomy/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/autonomy/lib/prerequisites.sh b/plugins/autonomy/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/autonomy/lib/prerequisites.sh +++ b/plugins/autonomy/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/bash-format/.claude-plugin/plugin.json b/plugins/bash-format/.claude-plugin/plugin.json index 94f5ab40e7..f8f3afdd10 100644 --- a/plugins/bash-format/.claude-plugin/plugin.json +++ b/plugins/bash-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "bash-format", - "version": "0.9.15", + "version": "0.10.0", "description": "Auto-format and lint shell scripts on edit via shfmt + ShellCheck, using the consuming repo's own .editorconfig and .shellcheckrc.", "author": { "name": "Melodic Software", diff --git a/plugins/bash-format/CHANGELOG.md b/plugins/bash-format/CHANGELOG.md index ed8086002c..1d64877700 100644 --- a/plugins/bash-format/CHANGELOG.md +++ b/plugins/bash-format/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `bash-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.10.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/bash-format:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.9.15] - 2026-10-02 ### Changed diff --git a/plugins/bash-format/hooks/bash-format.test.sh b/plugins/bash-format/hooks/bash-format.test.sh index ea5453980c..fbf227cf02 100755 --- a/plugins/bash-format/hooks/bash-format.test.sh +++ b/plugins/bash-format/hooks/bash-format.test.sh @@ -727,10 +727,12 @@ EXPECTED_IF="$(printf '%s\n' "$SCRIPT_EXTS" | sed 's/.*/Edit(&)/' | tr '\n' ' ') EXPECTED_IF="${EXPECTED_IF% }" EXPECTED_COUNT="$(printf '%s\n' "$SCRIPT_EXTS" | grep -c .)" if command -v jq >/dev/null 2>&1 && [[ -f "$HOOKS_JSON" && -n "$BEGIN_LINE" && "$EXPECTED_COUNT" -gt 0 ]]; then + # The node-notice SessionStart row is filtered out here and pinned fleet-wide by + # scripts/node-notice-rows.test.sh. # The one row outside this gate is the SessionStart prerequisite probe, exec # form behind the bash_format_enabled launcher gate, which is asserted on its # own here. - ALL_HANDLERS="$(jq -c '[.hooks | to_entries[] | .key as $ev | .value[]? | .matcher as $m | .hooks[]? | . + {event: $ev, matcher: ($m // "(none)")}]' "$HOOKS_JSON")" + ALL_HANDLERS="$(jq -c '[.hooks | to_entries[] | .key as $ev | .value[]? | .matcher as $m | .hooks[]? | select((.command // "") | contains("node-notice") | not) | . + {event: $ev, matcher: ($m // "(none)")}]' "$HOOKS_JSON")" PROBE_COUNT="$(jq -c --arg checker '${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs' --arg root '${CLAUDE_PLUGIN_ROOT}' '[.[] | select(.event == "SessionStart" and .command == "node" and .args == [$checker, "probe", $root, "--run-if-unset-or-true", "BASH_FORMAT_ENABLED"])] | length' <<<"$ALL_HANDLERS")" HANDLERS="$(jq -c --arg checker '${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs' --arg root '${CLAUDE_PLUGIN_ROOT}' '[.[] | select((.event == "SessionStart" and .command == "node" and .args == [$checker, "probe", $root, "--run-if-unset-or-true", "BASH_FORMAT_ENABLED"]) | not)]' <<<"$ALL_HANDLERS")" if [[ "$PROBE_COUNT" == "1" ]]; then diff --git a/plugins/bash-format/hooks/hook-utils.sh b/plugins/bash-format/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/bash-format/hooks/hook-utils.sh +++ b/plugins/bash-format/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/bash-format/hooks/hooks.json b/plugins/bash-format/hooks/hooks.json index 0f568439c4..91382c6253 100644 --- a/plugins/bash-format/hooks/hooks.json +++ b/plugins/bash-format/hooks/hooks.json @@ -47,6 +47,16 @@ "statusMessage": "Checking shfmt and shellcheck..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /bash-format:check BASH_FORMAT_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /bash-format:check BASH_FORMAT_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/bash-format/lib/prerequisites.ps1 b/plugins/bash-format/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/bash-format/lib/prerequisites.ps1 +++ b/plugins/bash-format/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/bash-format/lib/prerequisites.sh b/plugins/bash-format/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/bash-format/lib/prerequisites.sh +++ b/plugins/bash-format/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/biome-format/.claude-plugin/plugin.json b/plugins/biome-format/.claude-plugin/plugin.json index 348b8034a2..ed9a05ad08 100644 --- a/plugins/biome-format/.claude-plugin/plugin.json +++ b/plugins/biome-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "biome-format", - "version": "0.7.24", + "version": "0.8.0", "description": "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.", "author": { "name": "Melodic Software", diff --git a/plugins/biome-format/CHANGELOG.md b/plugins/biome-format/CHANGELOG.md index 0887d9ec71..a5fe8b580a 100644 --- a/plugins/biome-format/CHANGELOG.md +++ b/plugins/biome-format/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `biome-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/biome-format:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.7.24] - 2026-10-02 ### Changed diff --git a/plugins/biome-format/hooks/biome-format.test.sh b/plugins/biome-format/hooks/biome-format.test.sh index cb76b7a234..39fa413a78 100755 --- a/plugins/biome-format/hooks/biome-format.test.sh +++ b/plugins/biome-format/hooks/biome-format.test.sh @@ -537,10 +537,12 @@ EXPECTED_IF="$(printf '%s\n' "$SCRIPT_EXTS" | sed 's/.*/Edit(&)/' | tr '\n' ' ') EXPECTED_IF="${EXPECTED_IF% }" EXPECTED_COUNT="$(printf '%s\n' "$SCRIPT_EXTS" | grep -c .)" if command -v jq >/dev/null 2>&1 && [[ -f "$HOOKS_JSON" && -n "$BEGIN_LINE" && "$EXPECTED_COUNT" -gt 0 ]]; then + # The node-notice SessionStart row is filtered out here and pinned fleet-wide by + # scripts/node-notice-rows.test.sh. # The one row outside this gate is the SessionStart prerequisite probe, exec # form behind the biome_format_enabled launcher gate, which is asserted on its # own here. - ALL_HANDLERS="$(jq -c '[.hooks | to_entries[] | .key as $ev | .value[]? | .matcher as $m | .hooks[]? | . + {event: $ev, matcher: ($m // "(none)")}]' "$HOOKS_JSON")" + ALL_HANDLERS="$(jq -c '[.hooks | to_entries[] | .key as $ev | .value[]? | .matcher as $m | .hooks[]? | select((.command // "") | contains("node-notice") | not) | . + {event: $ev, matcher: ($m // "(none)")}]' "$HOOKS_JSON")" PROBE_COUNT="$(jq -c --arg checker '${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs' --arg root '${CLAUDE_PLUGIN_ROOT}' '[.[] | select(.event == "SessionStart" and .command == "node" and .args == [$checker, "probe", $root, "--run-if-unset-or-true", "BIOME_FORMAT_ENABLED"])] | length' <<<"$ALL_HANDLERS")" HANDLERS="$(jq -c --arg checker '${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs' --arg root '${CLAUDE_PLUGIN_ROOT}' '[.[] | select((.event == "SessionStart" and .command == "node" and .args == [$checker, "probe", $root, "--run-if-unset-or-true", "BIOME_FORMAT_ENABLED"]) | not)]' <<<"$ALL_HANDLERS")" if [[ "$PROBE_COUNT" == "1" ]]; then diff --git a/plugins/biome-format/hooks/hook-utils.sh b/plugins/biome-format/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/biome-format/hooks/hook-utils.sh +++ b/plugins/biome-format/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/biome-format/hooks/hooks.json b/plugins/biome-format/hooks/hooks.json index 47022ac56e..32899e741a 100644 --- a/plugins/biome-format/hooks/hooks.json +++ b/plugins/biome-format/hooks/hooks.json @@ -135,6 +135,16 @@ "statusMessage": "Checking biome..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /biome-format:check BIOME_FORMAT_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /biome-format:check BIOME_FORMAT_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/biome-format/lib/prerequisites.ps1 b/plugins/biome-format/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/biome-format/lib/prerequisites.ps1 +++ b/plugins/biome-format/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/biome-format/lib/prerequisites.sh b/plugins/biome-format/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/biome-format/lib/prerequisites.sh +++ b/plugins/biome-format/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/code-metrics/.claude-plugin/plugin.json b/plugins/code-metrics/.claude-plugin/plugin.json index 091b2d233a..c7d1237a31 100644 --- a/plugins/code-metrics/.claude-plugin/plugin.json +++ b/plugins/code-metrics/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-metrics", - "version": "0.5.1", + "version": "0.5.2", "description": "Read-only code measures for a change, with cited references and no verdict: lines per file (audit-size), cyclomatic, cognitive, and Halstead complexity (audit-complexity), duplication (audit-duplication), per-function coverage and CRAP from existing lcov, Cobertura, coverage.py, or Go artifacts (audit-coverage), TypeScript and Python type debt (audit-type-debt), metric literacy (principles), and setup. Uses only collectors already installed; never installs or runs tests.", "author": { "name": "Melodic Software", diff --git a/plugins/code-metrics/CHANGELOG.md b/plugins/code-metrics/CHANGELOG.md index 48bb190ce2..ca7eb8af13 100644 --- a/plugins/code-metrics/CHANGELOG.md +++ b/plugins/code-metrics/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `code-metrics` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.5.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.5.1] - 2026-10-02 ### Changed diff --git a/plugins/code-metrics/lib/prerequisites.ps1 b/plugins/code-metrics/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/code-metrics/lib/prerequisites.ps1 +++ b/plugins/code-metrics/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/code-metrics/lib/prerequisites.sh b/plugins/code-metrics/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/code-metrics/lib/prerequisites.sh +++ b/plugins/code-metrics/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/code-tidying/.claude-plugin/plugin.json b/plugins/code-tidying/.claude-plugin/plugin.json index 1b6d13fcf3..2248af2647 100644 --- a/plugins/code-tidying/.claude-plugin/plugin.json +++ b/plugins/code-tidying/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-tidying", - "version": "0.28.1", + "version": "0.28.2", "description": "Code tidying and comment hygiene. tidy hunts a rotated lane for Beck-style tidyings and ships one PR. batch-simplify sweeps a branch, time window, or repo in dependency-ordered waves. dissolve-comments deletes zero-information comments and moves the rest into names (safe, aggressive, strip modes). audit-comment-residue flags history, plan, conversational, and ticket residue. audit-dead-code finds dead code with knip, vulture, gopls, and grep. setup scaffolds tidy lanes.", "author": { "name": "Melodic Software", diff --git a/plugins/code-tidying/CHANGELOG.md b/plugins/code-tidying/CHANGELOG.md index 8d62aab7aa..1230ff50b6 100644 --- a/plugins/code-tidying/CHANGELOG.md +++ b/plugins/code-tidying/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to the `code-tidying` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.28.2] - 2026-10-03 + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. +- The `dissolve-comments` skill names `hook::require jq`, the helper's new spelling, in its example. + ## [0.28.1] - 2026-10-03 ### Fixed diff --git a/plugins/code-tidying/lib/prerequisites.ps1 b/plugins/code-tidying/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/code-tidying/lib/prerequisites.ps1 +++ b/plugins/code-tidying/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/code-tidying/lib/prerequisites.sh b/plugins/code-tidying/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/code-tidying/lib/prerequisites.sh +++ b/plugins/code-tidying/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/code-tidying/skills/dissolve-comments/SKILL.md b/plugins/code-tidying/skills/dissolve-comments/SKILL.md index 63609a047d..1e1869f594 100644 --- a/plugins/code-tidying/skills/dissolve-comments/SKILL.md +++ b/plugins/code-tidying/skills/dissolve-comments/SKILL.md @@ -264,7 +264,7 @@ from them. class-A input: it calls a comment code whenever the body reparses, so prose carrying backtick-quoted identifiers hits. A hit whose text is a sentence, not a statement, is prose. That test does not settle an indented usage example under a documentation block - (`# hook::require_jq PostToolUse my-plugin "$INPUT"`), which is a statement and still + (`# hook::require jq PostToolUse my-plugin "$INPUT"`), which is a statement and still documentation. Reading decides: a line demonstrating how to call the thing the block documents is prose, whatever it parses as. **Criterion 2 is decided on evidence, never on impression.** For every class-C candidate whose diff --git a/plugins/context-budget/.claude-plugin/plugin.json b/plugins/context-budget/.claude-plugin/plugin.json index 3a312933d9..d378fab6a8 100644 --- a/plugins/context-budget/.claude-plugin/plugin.json +++ b/plugins/context-budget/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "context-budget", - "version": "0.8.3", + "version": "0.9.0", "description": "Measures a Claude Code session's fixed startup context payload per item at a pinned binary version, including per-tool attribution of the built-in tool pools that /context reports only as totals, derived by A/B deny differencing under comparability rules. Falls back from an SDK meter to a headless /context parser, then to an error rather than a wrong number. Keeps a per-project before/after ledger per lever. Report-only by default; fix applies one approved project-scope trim.", "author": { "name": "Melodic Software", diff --git a/plugins/context-budget/CHANGELOG.md b/plugins/context-budget/CHANGELOG.md index 1cfeae73af..48f4bc3bde 100644 --- a/plugins/context-budget/CHANGELOG.md +++ b/plugins/context-budget/CHANGELOG.md @@ -7,6 +7,13 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Versions 0.6.38 and 0.6.40 were reserved by parallel changes and never published. +## [0.9.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/context-budget:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + ## [0.8.3] - 2026-10-02 ### Changed diff --git a/plugins/context-budget/hooks/hooks.json b/plugins/context-budget/hooks/hooks.json index c731489477..c465d98317 100644 --- a/plugins/context-budget/hooks/hooks.json +++ b/plugins/context-budget/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Asks before a file-editing tool writes a Claude Code settings file, where an unreviewed edit changes the harness itself. Shell writes and files rendered into place by other programs are not seen.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /context-budget:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /context-budget:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "PreToolUse": [ { "matcher": "Write|Edit|NotebookEdit", diff --git a/plugins/context-budget/lib/prerequisites.ps1 b/plugins/context-budget/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/context-budget/lib/prerequisites.ps1 +++ b/plugins/context-budget/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/context-budget/lib/prerequisites.sh b/plugins/context-budget/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/context-budget/lib/prerequisites.sh +++ b/plugins/context-budget/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/context-guard/.claude-plugin/plugin.json b/plugins/context-guard/.claude-plugin/plugin.json index 47a7f6b010..434e4ace27 100644 --- a/plugins/context-guard/.claude-plugin/plugin.json +++ b/plugins/context-guard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "context-guard", - "version": "0.9.5", + "version": "0.10.0", "description": "Per-session context-window observability. A statusline wrapper writes each session's context_window fields to a snapshot file; a resolver classifies usage into smart, acceptable, and dumb zones from zones.json. Hooks report each move into a worse zone once: the continuation menu to the operator, and only the zone to the model, noting a zone is not a decay signal. Optional blocking mode gates new mutating work in the dumb zone, except handoffs. A PostCompact hook leaves a marker.", "author": { "name": "Melodic Software", diff --git a/plugins/context-guard/CHANGELOG.md b/plugins/context-guard/CHANGELOG.md index 345f800eb3..02e328c7fb 100644 --- a/plugins/context-guard/CHANGELOG.md +++ b/plugins/context-guard/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the `context-guard` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/context-guard:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. +- Hooks call `hook::require jq` where they called `hook::require_jq`. + ## [0.9.5] - 2026-10-02 ### Changed diff --git a/plugins/context-guard/hooks/hook-utils.sh b/plugins/context-guard/hooks/hook-utils.sh index 4327545cef..24f5937c20 100755 --- a/plugins/context-guard/hooks/hook-utils.sh +++ b/plugins/context-guard/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/context-guard/hooks/hooks.json b/plugins/context-guard/hooks/hooks.json index 1beb4735f9..5f8118f218 100644 --- a/plugins/context-guard/hooks/hooks.json +++ b/plugins/context-guard/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Context-zone crossing notices, the pre-edit zone gate, and the post-compaction marker", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /context-guard:check CONTEXT_GUARD_HOOKS_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /context-guard:check CONTEXT_GUARD_HOOKS_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "PostToolBatch": [ { "hooks": [ diff --git a/plugins/context-guard/hooks/zone-crossing-inject.sh b/plugins/context-guard/hooks/zone-crossing-inject.sh index d1b6e14afd..c018e2fd22 100755 --- a/plugins/context-guard/hooks/zone-crossing-inject.sh +++ b/plugins/context-guard/hooks/zone-crossing-inject.sh @@ -203,7 +203,7 @@ CG_REQUIRED=0 cg_require_utils() { cg_load_utils ((CG_REQUIRED)) && return 0 - hook::require_jq "$EVENT" "context-guard" "$INPUT" + hook::require jq "$EVENT" "context-guard" "$INPUT" CG_REQUIRED=1 } diff --git a/plugins/context-guard/hooks/zone-crossing-inject.test.sh b/plugins/context-guard/hooks/zone-crossing-inject.test.sh index e5510267eb..596b37127b 100755 --- a/plugins/context-guard/hooks/zone-crossing-inject.test.sh +++ b/plugins/context-guard/hooks/zone-crossing-inject.test.sh @@ -858,7 +858,7 @@ fi # and NO jq, for the same reason A is free: the builtin parser answers the two # envelope fields of an ordinary-sized payload. # Anything else is a regression. The count is of commands in COMMAND POSITION -# (anchored on the xtrace depth prefix), so `command -v jq` in hook::require_jq +# (anchored on the xtrace depth prefix), so `command -v jq` in hook::require jq # is correctly not counted: it is a shell builtin and spawns nothing. # # HOOK_TELEMETRY_SINK is empty, as everywhere else in this file. With a sink diff --git a/plugins/context-guard/hooks/zone-gate.sh b/plugins/context-guard/hooks/zone-gate.sh index 5d6a97148c..b259567324 100755 --- a/plugins/context-guard/hooks/zone-gate.sh +++ b/plugins/context-guard/hooks/zone-gate.sh @@ -70,7 +70,7 @@ RESOLVER="$CG_DIR/../scripts/context-zone.sh" # Write's tool_input rides in this payload, and a single bounded read timing # out on it would fail the gate open for exactly the biggest writes. INPUT=$(cg::read_payload) || exit 0 -hook::require_jq "PreToolUse" "context-guard" "$INPUT" +hook::require jq "PreToolUse" "context-guard" "$INPUT" SESSION=$(hook::jq_field "$INPUT" '.session_id') || exit 0 [[ "$SESSION" =~ ^[A-Za-z0-9_-]+$ ]] || exit 0 diff --git a/plugins/context-guard/lib/prerequisites.ps1 b/plugins/context-guard/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/context-guard/lib/prerequisites.ps1 +++ b/plugins/context-guard/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/context-guard/lib/prerequisites.sh b/plugins/context-guard/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/context-guard/lib/prerequisites.sh +++ b/plugins/context-guard/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/context7/.claude-plugin/plugin.json b/plugins/context7/.claude-plugin/plugin.json index 0d3e93266d..5ee6481cd7 100644 --- a/plugins/context7/.claude-plugin/plugin.json +++ b/plugins/context7/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "context7", - "version": "0.6.5", + "version": "0.6.6", "description": "Looks up current library documentation, API references, and code examples via Context7 (ctx7 CLI or the Context7 MCP server) with a two-step resolve-then-query workflow: a lookup skill (default lookup plus an upstream drift-check action) and a setup skill for CLI install, auth, and MCP configuration.", "author": { "name": "Melodic Software", diff --git a/plugins/context7/CHANGELOG.md b/plugins/context7/CHANGELOG.md index aea6547af3..765fd4d49d 100644 --- a/plugins/context7/CHANGELOG.md +++ b/plugins/context7/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `context7` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.6] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.6.5] - 2026-10-03 ### Changed diff --git a/plugins/context7/lib/prerequisites.ps1 b/plugins/context7/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/context7/lib/prerequisites.ps1 +++ b/plugins/context7/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/context7/lib/prerequisites.sh b/plugins/context7/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/context7/lib/prerequisites.sh +++ b/plugins/context7/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/desktop-notification/.claude-plugin/plugin.json b/plugins/desktop-notification/.claude-plugin/plugin.json index 1914f9d1ef..2ae6a3f9e1 100644 --- a/plugins/desktop-notification/.claude-plugin/plugin.json +++ b/plugins/desktop-notification/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "desktop-notification", - "version": "0.7.10", + "version": "0.8.0", "description": "Alert you when Claude Code needs input: an audible terminal bell, an OSC 9 terminal notification, and an OS-native toast (macOS/Linux) on permission and idle prompts.", "author": { "name": "Melodic Software", diff --git a/plugins/desktop-notification/CHANGELOG.md b/plugins/desktop-notification/CHANGELOG.md index f3fa1e5420..2b54c32d2d 100644 --- a/plugins/desktop-notification/CHANGELOG.md +++ b/plugins/desktop-notification/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `desktop-notification` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/desktop-notification:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.7.10] - 2026-10-02 ### Changed diff --git a/plugins/desktop-notification/hooks/hook-utils.sh b/plugins/desktop-notification/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/desktop-notification/hooks/hook-utils.sh +++ b/plugins/desktop-notification/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/desktop-notification/hooks/hooks.json b/plugins/desktop-notification/hooks/hooks.json index b9a5b78866..11105db179 100644 --- a/plugins/desktop-notification/hooks/hooks.json +++ b/plugins/desktop-notification/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Raises a desktop notification when Claude Code needs attention on a permission or idle prompt.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /desktop-notification:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /desktop-notification:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "Notification": [ { "matcher": "permission_prompt|idle_prompt", diff --git a/plugins/desktop-notification/lib/prerequisites.ps1 b/plugins/desktop-notification/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/desktop-notification/lib/prerequisites.ps1 +++ b/plugins/desktop-notification/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/desktop-notification/lib/prerequisites.sh b/plugins/desktop-notification/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/desktop-notification/lib/prerequisites.sh +++ b/plugins/desktop-notification/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/disk-hygiene/.claude-plugin/plugin.json b/plugins/disk-hygiene/.claude-plugin/plugin.json index bd9bdb544e..c5ba742002 100644 --- a/plugins/disk-hygiene/.claude-plugin/plugin.json +++ b/plugins/disk-hygiene/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "disk-hygiene", - "version": "0.42.14", + "version": "0.43.0", "description": "Context-aware disk hygiene for arbitrary directory trees: inventories orphaned and temporary artifacts, classifies evidence into review tiers, and offers exact-path cleanup only after a fresh safety preview and explicit per-tier approval. The target is read-only by default; OS-managed paths, links and mount points, VCS-tracked content without the complete checkout evidence bundle, changed entries, and live-handle uncertainty fail closed.", "author": { "name": "Melodic Software", diff --git a/plugins/disk-hygiene/CHANGELOG.md b/plugins/disk-hygiene/CHANGELOG.md index 7ff4bee269..da26acc618 100644 --- a/plugins/disk-hygiene/CHANGELOG.md +++ b/plugins/disk-hygiene/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `disk-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.43.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/disk-hygiene:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The `SessionStart` node notice is the shared row instead of an inline bash command, so it also works without Git Bash and appears once per session across plugins. + ## [0.42.14] - 2026-10-02 ### Changed diff --git a/plugins/disk-hygiene/README.md b/plugins/disk-hygiene/README.md index c6f32ba9ed..53b0d4bb36 100644 --- a/plugins/disk-hygiene/README.md +++ b/plugins/disk-hygiene/README.md @@ -69,10 +69,11 @@ at preview. Backups remain the recovery boundary for user data. - Node.js on `PATH`. Every guard and detector registration runs `node hooks/exec-bash.mjs`, and Claude Code's native binary neither ships nor uses Node ([Setup](https://code.claude.com/docs/en/setup)), so without `node` no hook launches and no guard is enforced. A `SessionStart` row in shell form - (`"shell": "bash"`, no `args`) runs `command -v node` and needs no node itself. When node is + (no `shell` field and no `args`) runs `lib/prerequisites.sh node-notice`, or + `lib/prerequisites.ps1` where there is no `sh`, and needs no node itself. When node is absent it exits 0 with JSON: `systemMessage` shows the user a warning and `additionalContext` - tells the model that the destructive-delete guard cannot launch and enforces nothing. It prints - nothing when node is present. Basis: https://code.claude.com/docs/en/hooks, "SessionStart" + tells the model, once per session across plugins, and the notice names `/disk-hygiene:check`. + `disk_hygiene_enabled` set to false silences it. It prints nothing when node is present. Basis: https://code.claude.com/docs/en/hooks, "SessionStart" (plain stdout reaches Claude only, and exit-2 stderr reaches the user only) and "JSON output" (`systemMessage` is a warning shown to the user). - Bash that `hooks/exec-bash.mjs` can find. The file's header comment lists the candidates in diff --git a/plugins/disk-hygiene/hooks/hooks.json b/plugins/disk-hygiene/hooks/hooks.json index 67663fb811..1ae3d08a8f 100644 --- a/plugins/disk-hygiene/hooks/hooks.json +++ b/plugins/disk-hygiene/hooks/hooks.json @@ -6,8 +6,7 @@ "hooks": [ { "type": "command", - "shell": "bash", - "command": "command -v node >/dev/null 2>&1 || printf '%s\\n' '{\"systemMessage\":\"disk-hygiene: node is not on PATH, so its destructive-delete guard cannot launch and enforces nothing. Install Node.js and restart Claude Code.\",\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"WARNING: node is not on PATH, so the destructive-delete guard of the disk-hygiene plugin cannot launch and enforces nothing. Tell the user.\"}}'", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /disk-hygiene:check DISK_HYGIENE_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /disk-hygiene:check DISK_HYGIENE_ENABLED", "timeout": 10, "statusMessage": "Checking that node is on PATH..." } diff --git a/plugins/disk-hygiene/hooks/run-python-hook.test.sh b/plugins/disk-hygiene/hooks/run-python-hook.test.sh index 53ca4d988a..5ac6713694 100755 --- a/plugins/disk-hygiene/hooks/run-python-hook.test.sh +++ b/plugins/disk-hygiene/hooks/run-python-hook.test.sh @@ -871,25 +871,12 @@ assert_eq "the Stop row skips a session that launched no guard" "1" \ select(([.command] + ((.args // []) | map(tostring)) | join(" ")) | contains("--skip-unless-marker guard-launch-monitor"))] | length' \ "$HOOKS_JSON")" -# --- the SessionStart node notice is shell form, needs no node, and never blocks --- +# --- the SessionStart node notice is shell form and needs no node --- +# Its behavior, with and without node and under PowerShell, is run by +# scripts/node-notice-rows.test.sh; this pins that disk-hygiene keeps it shell form. notice="$(jq -c '.hooks.SessionStart[].hooks[]' "$HOOKS_JSON")" assert_eq "one SessionStart row" "1" "$(jq -s 'length' <<<"$notice")" -assert_eq "the SessionStart row is shell-form bash with no args" "true" \ - "$(jq '.type == "command" and .shell == "bash" and (has("args") | not) and (.command | startswith("node") | not)' <<<"$notice")" -notice_cmd="$(jq -r '.command' <<<"$notice")" -NONODE_DIR="$(mktemp -d)" -trap 'rm -rf "$FAKE_BIN" "$PROBE_DIR" "$PY_BIN" "$NOPY_DIR" "$NONODE_DIR"' EXIT -ln -s "$(command -v bash)" "$NONODE_DIR/bash" -notice_rc=0 -notice_out="$(PATH="$NONODE_DIR" "$NONODE_DIR/bash" -c "$notice_cmd" 2>&1)" || notice_rc=$? -assert_eq "the notice row exits 0 without node" "0" "$notice_rc" -assert_eq "the notice tells the user the guard cannot launch" "true" \ - "$(jq '.systemMessage | contains("cannot launch and enforces nothing")' <<<"$notice_out")" -assert_eq "the notice tells the model" "true" \ - "$(jq '.hookSpecificOutput | .hookEventName == "SessionStart" and (.additionalContext | contains("enforces nothing"))' <<<"$notice_out")" -notice_rc=0 -notice_out="$(bash -c "$notice_cmd" 2>&1)" || notice_rc=$? -assert_eq "the notice row exits 0 with node" "0" "$notice_rc" -assert_eq "the notice row is silent with node" "" "$notice_out" +assert_eq "the SessionStart row is shell form with no args" "true" \ + "$(jq '.type == "command" and (has("args") | not) and (.command | startswith("sh ") and contains("prerequisites.sh\" node-notice /disk-hygiene:check"))' <<<"$notice")" pass "all run-python-hook contract checks" diff --git a/plugins/disk-hygiene/lib/prerequisites.ps1 b/plugins/disk-hygiene/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/disk-hygiene/lib/prerequisites.ps1 +++ b/plugins/disk-hygiene/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/disk-hygiene/lib/prerequisites.sh b/plugins/disk-hygiene/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/disk-hygiene/lib/prerequisites.sh +++ b/plugins/disk-hygiene/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/docs-hygiene/.claude-plugin/plugin.json b/plugins/docs-hygiene/.claude-plugin/plugin.json index 54e189b97c..1b02dbaf9d 100644 --- a/plugins/docs-hygiene/.claude-plugin/plugin.json +++ b/plugins/docs-hygiene/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "docs-hygiene", - "version": "0.26.1", + "version": "0.26.2", "description": "Documentation-hygiene toolkit: compress (trim markdown with a semantic-diff check), audit-noise (classify markdown noise), extract-ssot (deduplicate into a single source of truth), audit-encapsulation (citations into skill-private files), rename-references (stale references after renames), audit-derivability (does a doc earn its existence), audit-progressive-disclosure (load tiers), write-for-agents and write-for-humans (authoring). Setup checks markdownlint-cli2.", "author": { "name": "Melodic Software", diff --git a/plugins/docs-hygiene/CHANGELOG.md b/plugins/docs-hygiene/CHANGELOG.md index 9e779837b5..37d73a3742 100644 --- a/plugins/docs-hygiene/CHANGELOG.md +++ b/plugins/docs-hygiene/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog: docs-hygiene plugin +## [0.26.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.26.1] - 2026-10-02 ### Changed diff --git a/plugins/docs-hygiene/lib/prerequisites.ps1 b/plugins/docs-hygiene/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/docs-hygiene/lib/prerequisites.ps1 +++ b/plugins/docs-hygiene/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/docs-hygiene/lib/prerequisites.sh b/plugins/docs-hygiene/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/docs-hygiene/lib/prerequisites.sh +++ b/plugins/docs-hygiene/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/docs-naming/.claude-plugin/plugin.json b/plugins/docs-naming/.claude-plugin/plugin.json index 338b9a3cb9..980fc39b6d 100644 --- a/plugins/docs-naming/.claude-plugin/plugin.json +++ b/plugins/docs-naming/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "docs-naming", - "version": "0.2.2", + "version": "0.2.3", "description": "File-name toolkit that plans, applies, and enforces a casing rule across a tracked tree: setup (the one configuration surface), audit-file-names (read-only inventory plus the reference sweep, writing the rename plan), realign-file-names (the executor, one human acceptance per file), and generate-file-name-gate (emits the standalone check that keeps the tree from drifting back).", "author": { "name": "Melodic Software", diff --git a/plugins/docs-naming/CHANGELOG.md b/plugins/docs-naming/CHANGELOG.md index 08f53530ab..363338f573 100644 --- a/plugins/docs-naming/CHANGELOG.md +++ b/plugins/docs-naming/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `docs-naming` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.2.3] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.2.2] - 2026-10-02 ### Changed diff --git a/plugins/docs-naming/lib/prerequisites.ps1 b/plugins/docs-naming/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/docs-naming/lib/prerequisites.ps1 +++ b/plugins/docs-naming/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/docs-naming/lib/prerequisites.sh b/plugins/docs-naming/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/docs-naming/lib/prerequisites.sh +++ b/plugins/docs-naming/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/dometrain/.claude-plugin/plugin.json b/plugins/dometrain/.claude-plugin/plugin.json index 8742f3e712..1fed092f33 100644 --- a/plugins/dometrain/.claude-plugin/plugin.json +++ b/plugins/dometrain/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "dometrain", - "version": "0.6.0", + "version": "0.6.1", "description": "Dometrain course-content grounding skills for a Dometrain MCP server: search lessons, pull curated lesson documents with on-screen code, and cite timestamped deep links. Requires an active Dometrain Pro subscription. Ships no server: install dometrain-mcp for the bundled one, or configure your own user-scope dometrain server. Includes a setup check and a grounding usage skill kept in sync with Dometrain's own official Claude Code plugin.", "author": { "name": "Melodic Software", diff --git a/plugins/dometrain/CHANGELOG.md b/plugins/dometrain/CHANGELOG.md index 52f9cb73ed..72f3c64b4f 100644 --- a/plugins/dometrain/CHANGELOG.md +++ b/plugins/dometrain/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `dometrain` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.6.0] - 2026-10-02 ### Added diff --git a/plugins/dometrain/lib/prerequisites.ps1 b/plugins/dometrain/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/dometrain/lib/prerequisites.ps1 +++ b/plugins/dometrain/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/dometrain/lib/prerequisites.sh b/plugins/dometrain/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/dometrain/lib/prerequisites.sh +++ b/plugins/dometrain/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/eol-normalizer/.claude-plugin/plugin.json b/plugins/eol-normalizer/.claude-plugin/plugin.json index 0e5fa76c73..3a956c2a8b 100644 --- a/plugins/eol-normalizer/.claude-plugin/plugin.json +++ b/plugins/eol-normalizer/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "eol-normalizer", - "version": "0.8.10", + "version": "0.9.0", "description": "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.", "author": { "name": "Melodic Software", diff --git a/plugins/eol-normalizer/CHANGELOG.md b/plugins/eol-normalizer/CHANGELOG.md index 1f8efab475..5ad27ad957 100644 --- a/plugins/eol-normalizer/CHANGELOG.md +++ b/plugins/eol-normalizer/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `eol-normalizer` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.9.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/eol-normalizer:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.8.10] - 2026-10-02 ### Changed diff --git a/plugins/eol-normalizer/hooks/eol-normalizer.test.sh b/plugins/eol-normalizer/hooks/eol-normalizer.test.sh index f48bfadad7..b92ce46a57 100755 --- a/plugins/eol-normalizer/hooks/eol-normalizer.test.sh +++ b/plugins/eol-normalizer/hooks/eol-normalizer.test.sh @@ -49,10 +49,11 @@ trap cleanup EXIT # under the same environment, so the row and the script cannot disagree. # This section does not need git, so it runs before the no-git exit below. HOOKS_JSON="$HOOK_DIR/hooks.json" -if jq -e '[.hooks[][].hooks[]] | length == 1' "$HOOKS_JSON" >/dev/null; then +# The SessionStart node-notice row is pinned fleet-wide by scripts/node-notice-rows.test.sh. +if jq -e '[.hooks | del(.SessionStart)[][].hooks[]] | length == 1' "$HOOKS_JSON" >/dev/null; then ok "row-gate: hooks.json registers exactly one hook command" else - fail "row-gate: hooks.json registers $(jq '[.hooks[][].hooks[]] | length' "$HOOKS_JSON" 2>&1) hook commands, want 1" + fail "row-gate: hooks.json registers $(jq '[.hooks | del(.SessionStart)[][].hooks[]] | length' "$HOOKS_JSON" 2>&1) hook commands, want 1" fi ROW_JSON=$(jq -c '.hooks.PostToolUse[0].hooks[0]' "$HOOKS_JSON") ROW_CMD=$(jq -r '.command' <<<"$ROW_JSON") diff --git a/plugins/eol-normalizer/hooks/hook-utils.sh b/plugins/eol-normalizer/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/eol-normalizer/hooks/hook-utils.sh +++ b/plugins/eol-normalizer/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/eol-normalizer/hooks/hooks.json b/plugins/eol-normalizer/hooks/hooks.json index 88bcb43710..02fdec85d3 100644 --- a/plugins/eol-normalizer/hooks/hooks.json +++ b/plugins/eol-normalizer/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Normalizes line endings after a write or edit so a checkout's own EOL policy holds.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /eol-normalizer:check EOL_NORMALIZER_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /eol-normalizer:check EOL_NORMALIZER_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "PostToolUse": [ { "matcher": "Write|Edit", diff --git a/plugins/eol-normalizer/lib/prerequisites.ps1 b/plugins/eol-normalizer/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/eol-normalizer/lib/prerequisites.ps1 +++ b/plugins/eol-normalizer/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/eol-normalizer/lib/prerequisites.sh b/plugins/eol-normalizer/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/eol-normalizer/lib/prerequisites.sh +++ b/plugins/eol-normalizer/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/evals/.claude-plugin/plugin.json b/plugins/evals/.claude-plugin/plugin.json index b06d6a258d..93eece1553 100644 --- a/plugins/evals/.claude-plugin/plugin.json +++ b/plugins/evals/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "evals", - "version": "0.5.0", + "version": "0.5.1", "description": "LLM evaluation methodology and eval-suite design, based on Anthropic's evaluation guidance. /evals:methodology answers questions on success criteria, eval design, and grading. /evals:design interviews for measurable success criteria and scaffolds a graded eval suite for an LLM app or Claude Code skill. /evals:plugin-eval preflights the CLI and target, prices a suite before running, and reads the with-versus-without delta. /evals:validate checks case files with no model call.", "author": { "name": "Melodic Software", diff --git a/plugins/evals/CHANGELOG.md b/plugins/evals/CHANGELOG.md index a576007d1a..0ca341208a 100644 --- a/plugins/evals/CHANGELOG.md +++ b/plugins/evals/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog: evals +## [0.5.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.5.0] - 2026-10-02 ### Added diff --git a/plugins/evals/lib/prerequisites.ps1 b/plugins/evals/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/evals/lib/prerequisites.ps1 +++ b/plugins/evals/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/evals/lib/prerequisites.sh b/plugins/evals/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/evals/lib/prerequisites.sh +++ b/plugins/evals/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/explainer-video/.claude-plugin/plugin.json b/plugins/explainer-video/.claude-plugin/plugin.json index e11c85beaa..040b819758 100644 --- a/plugins/explainer-video/.claude-plugin/plugin.json +++ b/plugins/explainer-video/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "explainer-video", - "version": "0.1.1", + "version": "0.1.2", "description": "Short silent explainer videos made with ManimCE. The produce skill plans the beats, writes one scene script (Text for words, MathTypst for math), renders it at low quality first, and checks every render: ffprobe confirms one video stream, no audio and the scene's duration, a frame is read back after every animation, and overlapping or clipped text fails. A SessionStart hook installs the hash-locked Python packages; the check skill reports whether Python, ManimCE, ffmpeg and ffprobe are ready.", "author": { "name": "Melodic Software", diff --git a/plugins/explainer-video/CHANGELOG.md b/plugins/explainer-video/CHANGELOG.md index 072b7b19e6..585a4fed21 100644 --- a/plugins/explainer-video/CHANGELOG.md +++ b/plugins/explainer-video/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `explainer-video` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.1.2] - 2026-10-03 + +### Changed + +- **SessionStart reports a missing node.** One shell-form row runs the shared node-notice, and shared `hook-utils.sh`, `prerequisites.sh`, `prerequisites.ps1` are synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)). + ## [0.1.1] - 2026-10-03 ### Changed diff --git a/plugins/explainer-video/hooks/hook-utils.sh b/plugins/explainer-video/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/explainer-video/hooks/hook-utils.sh +++ b/plugins/explainer-video/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/explainer-video/hooks/hooks.json b/plugins/explainer-video/hooks/hooks.json index 4a195c76ef..47e7d244a1 100644 --- a/plugins/explainer-video/hooks/hooks.json +++ b/plugins/explainer-video/hooks/hooks.json @@ -15,6 +15,16 @@ "statusMessage": "Installing explainer-video Python packages..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /explainer-video:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /explainer-video:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/explainer-video/lib/prerequisites.ps1 b/plugins/explainer-video/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/explainer-video/lib/prerequisites.ps1 +++ b/plugins/explainer-video/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/explainer-video/lib/prerequisites.sh b/plugins/explainer-video/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/explainer-video/lib/prerequisites.sh +++ b/plugins/explainer-video/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/firecrawl/.claude-plugin/plugin.json b/plugins/firecrawl/.claude-plugin/plugin.json index cb64685992..551d8cd1e2 100644 --- a/plugins/firecrawl/.claude-plugin/plugin.json +++ b/plugins/firecrawl/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "firecrawl", - "version": "0.6.0", + "version": "0.6.1", "description": "Web scraping, search, crawling, and file parsing through the firecrawl-cli binary with a write-to-disk-then-Read pattern that keeps large results out of context: a user-facing wrapper skill, a lazy-install setup skill, and a separate gated maintainer update skill tracking the upstream CLI and skill source.", "author": { "name": "Melodic Software", diff --git a/plugins/firecrawl/CHANGELOG.md b/plugins/firecrawl/CHANGELOG.md index fe5c3d7fe8..69b22c662a 100644 --- a/plugins/firecrawl/CHANGELOG.md +++ b/plugins/firecrawl/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `firecrawl` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.6.0] - 2026-10-02 ### Added diff --git a/plugins/firecrawl/lib/prerequisites.ps1 b/plugins/firecrawl/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/firecrawl/lib/prerequisites.ps1 +++ b/plugins/firecrawl/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/firecrawl/lib/prerequisites.sh b/plugins/firecrawl/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/firecrawl/lib/prerequisites.sh +++ b/plugins/firecrawl/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/gaming/.claude-plugin/plugin.json b/plugins/gaming/.claude-plugin/plugin.json index 0a3bdbe8a6..108c42f61a 100644 --- a/plugins/gaming/.claude-plugin/plugin.json +++ b/plugins/gaming/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gaming", - "version": "0.10.0", + "version": "0.10.1", "description": "Apply, track, tune, and remove the community DLSS 5 Neural Rendering mod (OptiScaler forks) in Windows PC games from Steam, Epic, EA app, Battle.net, GOG, Ubisoft Connect, and Xbox (gaming:dlss5). Anti-cheat checks refuse unless you type an at-your-own-risk acknowledgement. Snapshots allow byte-exact removal; a ledger and upstream watch track fork and driver releases. Ships no NVIDIA binary: the DLL comes from a path, an installed DLSS 5 title, or a configured source.", "author": { "name": "Melodic Software", diff --git a/plugins/gaming/CHANGELOG.md b/plugins/gaming/CHANGELOG.md index 5e091414c6..dda7a5b42c 100644 --- a/plugins/gaming/CHANGELOG.md +++ b/plugins/gaming/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `gaming` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.10.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.10.0] - 2026-10-02 ### Added diff --git a/plugins/gaming/lib/prerequisites.ps1 b/plugins/gaming/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/gaming/lib/prerequisites.ps1 +++ b/plugins/gaming/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/gaming/lib/prerequisites.sh b/plugins/gaming/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/gaming/lib/prerequisites.sh +++ b/plugins/gaming/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/go-format/.claude-plugin/plugin.json b/plugins/go-format/.claude-plugin/plugin.json index ebeeb7c879..78c82acedc 100644 --- a/plugins/go-format/.claude-plugin/plugin.json +++ b/plugins/go-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "go-format", - "version": "0.4.27", + "version": "0.5.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", diff --git a/plugins/go-format/CHANGELOG.md b/plugins/go-format/CHANGELOG.md index 5e25d89c51..7ce64772b4 100644 --- a/plugins/go-format/CHANGELOG.md +++ b/plugins/go-format/CHANGELOG.md @@ -3,6 +3,17 @@ 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.5.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/go-format:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.4.27] - 2026-10-02 ### Changed diff --git a/plugins/go-format/hooks/go-format.test.sh b/plugins/go-format/hooks/go-format.test.sh index 5255343f04..50a5fa18bc 100755 --- a/plugins/go-format/hooks/go-format.test.sh +++ b/plugins/go-format/hooks/go-format.test.sh @@ -526,9 +526,11 @@ EXPECTED_IF="$(printf '%s\n' "$SCRIPT_EXTS" | sed 's/.*/Edit(&)/' | tr '\n' ' ') EXPECTED_IF="${EXPECTED_IF% }" EXPECTED_COUNT="$(printf '%s\n' "$SCRIPT_EXTS" | grep -c .)" if command -v jq >/dev/null 2>&1 && [[ -f "$HOOKS_JSON" && -n "$BEGIN_LINE" && "$EXPECTED_COUNT" -gt 0 ]]; then + # The node-notice SessionStart row is filtered out here and pinned fleet-wide by + # scripts/node-notice-rows.test.sh. # The one row outside this gate is the SessionStart prerequisite probe, exec # form, which is asserted on its own here. - ALL_HANDLERS="$(jq -c '[.hooks | to_entries[] | .key as $ev | .value[]? | .matcher as $m | .hooks[]? | . + {event: $ev, matcher: ($m // "(none)")}]' "$HOOKS_JSON")" + ALL_HANDLERS="$(jq -c '[.hooks | to_entries[] | .key as $ev | .value[]? | .matcher as $m | .hooks[]? | select((.command // "") | contains("node-notice") | not) | . + {event: $ev, matcher: ($m // "(none)")}]' "$HOOKS_JSON")" PROBE_COUNT="$(jq -c --arg checker '${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs' --arg root '${CLAUDE_PLUGIN_ROOT}' '[.[] | select(.event == "SessionStart" and .command == "node" and .args == [$checker, "probe", $root, "--run-if-unset-or-true", "GO_FORMAT_ENABLED"])] | length' <<<"$ALL_HANDLERS")" HANDLERS="$(jq -c --arg checker '${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs' --arg root '${CLAUDE_PLUGIN_ROOT}' '[.[] | select((.event == "SessionStart" and .command == "node" and .args == [$checker, "probe", $root, "--run-if-unset-or-true", "GO_FORMAT_ENABLED"]) | not)]' <<<"$ALL_HANDLERS")" if [[ "$PROBE_COUNT" == "1" ]]; then diff --git a/plugins/go-format/hooks/hook-utils.sh b/plugins/go-format/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/go-format/hooks/hook-utils.sh +++ b/plugins/go-format/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/go-format/hooks/hooks.json b/plugins/go-format/hooks/hooks.json index aaad46ee4c..b2b06576f5 100644 --- a/plugins/go-format/hooks/hooks.json +++ b/plugins/go-format/hooks/hooks.json @@ -36,6 +36,16 @@ "statusMessage": "Checking goimports..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /go-format:check GO_FORMAT_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /go-format:check GO_FORMAT_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/go-format/lib/prerequisites.ps1 b/plugins/go-format/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/go-format/lib/prerequisites.ps1 +++ b/plugins/go-format/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/go-format/lib/prerequisites.sh b/plugins/go-format/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/go-format/lib/prerequisites.sh +++ b/plugins/go-format/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/guardrails/.claude-plugin/plugin.json b/plugins/guardrails/.claude-plugin/plugin.json index c4140e21c8..d1b8bac6b9 100644 --- a/plugins/guardrails/.claude-plugin/plugin.json +++ b/plugins/guardrails/.claude-plugin/plugin.json @@ -170,5 +170,5 @@ "min": 1 } }, - "version": "0.46.14" + "version": "0.47.0" } diff --git a/plugins/guardrails/CHANGELOG.md b/plugins/guardrails/CHANGELOG.md index 8ad6264896..ba9beee518 100644 --- a/plugins/guardrails/CHANGELOG.md +++ b/plugins/guardrails/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `guardrails` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.47.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/guardrails:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The `SessionStart` node notice is the shared row instead of an inline bash command, so it also works without Git Bash and appears once per session across plugins. +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. +- Hooks call `hook::require jq` where they called `hook::require_jq`. + ## [0.46.14] - 2026-10-02 ### Changed diff --git a/plugins/guardrails/README.md b/plugins/guardrails/README.md index 5aa0ee476b..75a916f2ec 100644 --- a/plugins/guardrails/README.md +++ b/plugins/guardrails/README.md @@ -1379,10 +1379,11 @@ as before. resolvable on `PATH`, and we treat a row that cannot start because `node` is missing as a guard that enforced nothing, with no documented error to rely on. `/guardrails:check` reports a missing `node` or `jq`. A `SessionStart` row in shell form - (`"shell": "bash"`, no `args`) runs `command -v node` and needs no node itself. When node is + (no `shell` field and no `args`) runs `lib/prerequisites.sh node-notice`, or + `lib/prerequisites.ps1` where there is no `sh`, and needs no node itself. When node is absent it exits 0 with JSON: `systemMessage` shows the user a warning and `additionalContext` - tells the model that the guards cannot launch and enforce nothing. It prints nothing when node - is present. It does not read the per-guard toggles, because an unset toggle exports no + tells the model, once per session across plugins, and the notice names `/guardrails:check`. + It prints nothing when node is present. It does not read the per-guard toggles, because an unset toggle exports no environment variable and the row would need every guard's key listed by hand; a host that turns every guard off should disable the plugin instead. Pointer: for how an exec-form `command` resolves, see ; for a hook that diff --git a/plugins/guardrails/hooks/abort-boundary.sh b/plugins/guardrails/hooks/abort-boundary.sh index 2511062fda..3783a2bc47 100644 --- a/plugins/guardrails/hooks/abort-boundary.sh +++ b/plugins/guardrails/hooks/abort-boundary.sh @@ -18,7 +18,7 @@ # # installs an EXIT trap that passes every CHOSEN status through untouched (the # hook's own `exit 0` / `exit 2`, including the ones the shared helpers make on -# its behalf: hook::check_enabled, hook::require_jq, hook::require_jq_blocking) +# its behalf: hook::check_enabled, hook::require jq, hook::require_jq_blocking) # and treats any other status as "the guard did not run". For those it writes # one line naming the hook and the status to stderr, and then applies the # hook's declared posture: diff --git a/plugins/guardrails/hooks/abort-boundary.test.sh b/plugins/guardrails/hooks/abort-boundary.test.sh index 8f2897332a..27b00eba8b 100755 --- a/plugins/guardrails/hooks/abort-boundary.test.sh +++ b/plugins/guardrails/hooks/abort-boundary.test.sh @@ -290,7 +290,7 @@ done # Deeper than the prologue: hook-utils.sh has loaded and stdin has been read # when a shared helper, redefined to trip an unbound expansion, aborts the # guard. One blocking hook (hook::jq_fields) on a payload it would otherwise -# DENY, one advisory hook (hook::require_jq) on its normal PostToolUse payload. +# DENY, one advisory hook (hook::require jq) on its normal PostToolUse payload. MID="$TEST_TMPDIR/mid" cp -R "$PLUGIN_DIR" "$MID" inject_after "$MID/hooks/block-windows-drive-tmp.sh" '^source "[$]_HOOK_SELF/hook-utils.sh"' \ @@ -309,7 +309,7 @@ assert_exit "block-windows-drive-tmp: shipped guard still denies D:/tmp" 2 "$RC" assert_absent "block-windows-drive-tmp: a deny carries no abort notice" "$ERR$OUT" "$NOTICE" inject_after "$MID/hooks/cli-flag-verify.sh" '^source "[$]_HOOK_SELF/hook-utils.sh"' \ - "hook::require_jq() { : \"\${${MARKER}?forced abort in hook::require_jq}\"; }" + "hook::require() { : \"\${${MARKER}?forced abort in hook::require}\"; }" mkdir -p "$TEST_TMPDIR/data" run_hook CLAUDE_PLUGIN_ROOT="$MID" CLAUDE_PLUGIN_DATA="$TEST_TMPDIR/data" -- \ feed_run "$(write_json "$TEST_TMPDIR/notes.md" 'run git status')" bash "$MID/hooks/cli-flag-verify.sh" diff --git a/plugins/guardrails/hooks/block-convention-violation.sh b/plugins/guardrails/hooks/block-convention-violation.sh index fe6d7603bc..46b87f75c7 100755 --- a/plugins/guardrails/hooks/block-convention-violation.sh +++ b/plugins/guardrails/hooks/block-convention-violation.sh @@ -79,13 +79,13 @@ hook::buffer_stdin_to INPUT || { exit 0 } -hook::require_jq "PreToolUse" "guardrails-block-convention-violation" "$INPUT" +hook::require jq "PreToolUse" "guardrails-block-convention-violation" "$INPUT" # All three payload fields in ONE jq process (hook::jq_fields), not three. A jq # spawn is fork() emulation on Windows Git Bash and this guard runs on every # Bash/PowerShell call. Failure semantics are unchanged: a missing jq or an # unparsable payload yields rc 1 here, which exits 0 exactly as the empty-COMMAND -# skip below did — hook::require_jq above has already made the degraded state +# skip below did — hook::require jq above has already made the degraded state # visible once per session and agent. The `// "Bash"` default moves to the bash-side # expansion, matching block-dangerous-git. hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' '.cwd' || exit 0 diff --git a/plugins/guardrails/hooks/block-hook-bypass.sh b/plugins/guardrails/hooks/block-hook-bypass.sh index 35e47ca8bb..8ca8ab9e4b 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.sh @@ -111,7 +111,7 @@ start=${EPOCHREALTIME:-} # a prefix is deliberately not rc 3: payload size and host load both move # it, so it stays a block. Buffering does not require jq # (hook::buffer_stdin's own JSON-completeness check is jq-optional), so it -# runs before the jq gate below — hook::require_jq needs the buffered input +# runs before the jq gate below — hook::require jq needs the buffered input # for its once per session and agent notice scoping. hook::buffer_stdin_to INPUT || { rc=$? @@ -132,17 +132,17 @@ hook::buffer_stdin_to INPUT || { exit 0 } -# jq is required to parse the tool payload. hook::require_jq fails OPEN +# jq is required to parse the tool payload. hook::require jq fails OPEN # (advisory hooks never block over a missing prerequisite) but makes the # degraded state visible to both the user (systemMessage) and the agent # (additionalContext), once per session and agent — see docs/conventions/hook-observability/. -hook::require_jq "PreToolUse" "guardrails-block-hook-bypass" "$INPUT" +hook::require jq "PreToolUse" "guardrails-block-hook-bypass" "$INPUT" # All three payload fields in ONE jq process (hook::jq_fields), not three. A jq spawn is # fork() emulation on Windows Git Bash and this guard runs on every Bash/PowerShell # call. Failure semantics are unchanged: a missing jq or an unparsable payload # yields rc 1 here, which exits 0 exactly as the empty-COMMAND skip below did — -# hook::require_jq above has already made the degraded state visible once per +# hook::require jq above has already made the degraded state visible once per # session and agent. The `// "Bash"` default moves to the bash-side expansion, matching # block-dangerous-git. hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' '.cwd' || exit 0 diff --git a/plugins/guardrails/hooks/block-hook-bypass.test.sh b/plugins/guardrails/hooks/block-hook-bypass.test.sh index 40c85b15d0..cbc76a43c0 100755 --- a/plugins/guardrails/hooks/block-hook-bypass.test.sh +++ b/plugins/guardrails/hooks/block-hook-bypass.test.sh @@ -1704,7 +1704,7 @@ assert_contains "latch (dispatched): first block carries the notice" "$GUARD_OUT guard_invoke --via dispatched --payload "$LATCH_PAYLOAD" \ -- CLAUDE_PROJECT_DIR= "CLAUDE_PLUGIN_DATA=$LATCH_DIR2" assert_absent "latch (dispatched): second block emits no notice" "$GUARD_OUT" "levers, narrowest first" -# Without jq the guard cannot read the payload and allows (hook::require_jq), so +# Without jq the guard cannot read the payload and allows (hook::require jq), so # the latch must not be spent on a run that never blocked: the next block with jq # back still carries the notice. run_guards::emit_one keeps one document there. LATCH_NOJQ_PATH="" diff --git a/plugins/guardrails/hooks/block-noncanonical-commit.sh b/plugins/guardrails/hooks/block-noncanonical-commit.sh index edb64f58f8..b83f387c58 100755 --- a/plugins/guardrails/hooks/block-noncanonical-commit.sh +++ b/plugins/guardrails/hooks/block-noncanonical-commit.sh @@ -118,7 +118,7 @@ start=${EPOCHREALTIME:-} # pipe, a transport fault) is a loud skip the dispatcher takes once. # Buffering does not require jq # (hook::buffer_stdin's own JSON-completeness check is jq-optional), so it runs -# before the jq gate below — hook::require_jq needs the buffered input for its +# before the jq gate below — hook::require jq needs the buffered input for its # once per session and agent notice scoping. hook::buffer_stdin_to INPUT || { rc=$? @@ -126,17 +126,17 @@ hook::buffer_stdin_to INPUT || { exit 0 } -# jq is required to parse the tool payload. hook::require_jq fails OPEN +# jq is required to parse the tool payload. hook::require jq fails OPEN # (advisory hooks never block over a missing prerequisite) but makes the # degraded state visible to both the user (systemMessage) and the agent # (additionalContext), once per session and agent — see docs/conventions/hook-observability/. -hook::require_jq "PreToolUse" "guardrails-block-noncanonical-commit" "$INPUT" +hook::require jq "PreToolUse" "guardrails-block-noncanonical-commit" "$INPUT" # All three payload fields in ONE jq process (hook::jq_fields), not three. A jq # spawn is fork() emulation on Windows Git Bash and this guard runs on every # Bash/PowerShell call. Failure semantics are unchanged: a missing jq or an # unparsable payload yields rc 1 here, which exits 0 exactly as the empty-COMMAND -# skip below did — hook::require_jq above has already made the degraded state +# skip below did — hook::require jq above has already made the degraded state # visible once per session and agent. The `// "Bash"` default moves to the bash-side # expansion, matching block-dangerous-git. hook::jq_fields "$INPUT" '.tool_input.command' '.cwd' '.tool_name' || exit 0 diff --git a/plugins/guardrails/hooks/cli-flag-verify.sh b/plugins/guardrails/hooks/cli-flag-verify.sh index fc96d269de..70448bda29 100755 --- a/plugins/guardrails/hooks/cli-flag-verify.sh +++ b/plugins/guardrails/hooks/cli-flag-verify.sh @@ -64,16 +64,16 @@ VERIFIER="$PLUGIN_ROOT/lib/verification/verify-cli-flag.sh" # hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read; empty # or timed-out stdin skips this advisory hook. Buffering does not require jq # (hook::buffer_stdin's own JSON-completeness check is jq-optional), so it -# runs before the jq gate below — hook::require_jq needs the buffered input +# runs before the jq gate below — hook::require jq needs the buffered input # for its once per session and agent notice scoping, and hook::read_file_path_to # (next) falls back to jq for a payload its builtin parse cannot prove. hook::buffer_stdin_to INPUT || exit 0 -# jq is required to parse the tool payload. hook::require_jq fails OPEN +# jq is required to parse the tool payload. hook::require jq fails OPEN # (this hook never blocks) but makes the degraded state visible to both the # user (systemMessage) and the agent (additionalContext), once per session and # agent — see docs/conventions/hook-observability/. -hook::require_jq "PostToolUse" "guardrails-cli-flag-verify" "$INPUT" +hook::require jq "PostToolUse" "guardrails-cli-flag-verify" "$INPUT" FILE="" hook::read_file_path_to FILE "$INPUT" || exit 0 @@ -98,7 +98,7 @@ esac # Bash. Selecting the field inside jq would still cost the same process, so both # are fetched and the tool-specific choice happens below in the shell. Failure # semantics are unchanged: a missing jq or an unparsable payload yields rc 1 -# here, which exits 0 exactly as the unmatched-TOOL case did — hook::require_jq +# here, which exits 0 exactly as the unmatched-TOOL case did — hook::require jq # above has already made the degraded state visible once per session and agent. hook::jq_fields "$INPUT" '.tool_name' '.tool_input.new_string' '.tool_input.content' || exit 0 TOOL="${HOOK_JQ_FIELDS[0]}" diff --git a/plugins/guardrails/hooks/exec-bash.test.sh b/plugins/guardrails/hooks/exec-bash.test.sh index 5dc5430742..b9e7f69abd 100755 --- a/plugins/guardrails/hooks/exec-bash.test.sh +++ b/plugins/guardrails/hooks/exec-bash.test.sh @@ -83,25 +83,12 @@ done <<<"$rows" [[ "$dispatcher" -ge 8 ]] || fail "expected the dispatcher rows, found $dispatcher" [[ "$workflow" -eq 1 ]] || fail "expected one workflow row, found $workflow" -# --- SessionStart node notice: shell form, needs no node, never blocks ------- +# --- SessionStart node notice: shell form, needs no node --------------------- +# The row's behavior, with and without node and under PowerShell, is run by +# scripts/node-notice-rows.test.sh; this pins that guardrails keeps it shell form. notice=$(jq -c '.hooks.SessionStart[].hooks[]' "$HOOKS_JSON") [[ "$(jq -s 'length' <<<"$notice")" -eq 1 ]] || fail "expected one SessionStart row" -jq -e '.type == "command" and .shell == "bash" and (has("args") | not) and (.command | contains("node") and (startswith("node") | not))' \ - <<<"$notice" >/dev/null || fail "SessionStart row is not shell-form bash: $notice" -notice_cmd=$(jq -r '.command' <<<"$notice") -bash_path=$(command -v bash) -nonode_dir="$TEST_TMPDIR/nonode" -mkdir -p "$nonode_dir" -ln -s "$bash_path" "$nonode_dir/bash" -rc=0 -out=$(PATH="$nonode_dir" "$bash_path" -c "$notice_cmd" 2>&1) || rc=$? -[[ "$rc" -eq 0 ]] || fail "notice row exited $rc without node" -jq -e '.systemMessage | contains("guards cannot launch and enforce nothing")' <<<"$out" >/dev/null || - fail "user notice missing without node: $out" -jq -e '.hookSpecificOutput | .hookEventName == "SessionStart" and (.additionalContext | contains("enforce nothing"))' <<<"$out" >/dev/null || - fail "model context missing without node: $out" -rc=0 -out=$(bash -c "$notice_cmd" 2>&1) || rc=$? -[[ "$rc" -eq 0 && -z "$out" ]] || fail "notice row is not silent with node (rc=$rc): $out" +jq -e '.type == "command" and (has("args") | not) and (.command | startswith("sh ") and contains("prerequisites.sh\" node-notice /guardrails:check"))' \ + <<<"$notice" >/dev/null || fail "SessionStart row is not the shell-form node notice: $notice" echo "exec-bash: stdin, exit 2, Git Bash resolution, and hooks.json shape passed ($dispatcher dispatcher rows)." diff --git a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh index cd4b9071ca..1b571d2e56 100755 --- a/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh +++ b/plugins/guardrails/hooks/flag-commit-pr-skill-bypass.sh @@ -86,21 +86,21 @@ start=${EPOCHREALTIME:-} # hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read; empty # or timed-out stdin skips this advisory hook. Buffering does not require jq # (hook::buffer_stdin's own JSON-completeness check is jq-optional), so it -# runs before the jq gate below — hook::require_jq needs the buffered input +# runs before the jq gate below — hook::require jq needs the buffered input # for its once per session and agent notice scoping. hook::buffer_stdin_to INPUT || exit 0 # jq is required both to parse the tool payload and to read enabledPlugins. -# hook::require_jq fails OPEN (this hook never blocks either way) but makes +# hook::require jq fails OPEN (this hook never blocks either way) but makes # the degraded state visible to both the user (systemMessage) and the agent # (additionalContext), once per session and agent — see docs/conventions/hook-observability/. -hook::require_jq "PreToolUse" "guardrails-flag-commit-pr-skill-bypass" "$INPUT" +hook::require jq "PreToolUse" "guardrails-flag-commit-pr-skill-bypass" "$INPUT" # Both payload fields in ONE jq process (hook::jq_fields), not two. A jq spawn is # fork() emulation on Windows Git Bash and this hook runs on every Bash/PowerShell # call. Failure semantics are unchanged: a missing jq or an unparsable payload # yields rc 1 here, which exits 0 exactly as the empty-COMMAND skip below did — -# hook::require_jq above has already made the degraded state visible once per +# hook::require jq above has already made the degraded state visible once per # session and agent. The `// "Bash"` default moves to the bash-side expansion, # matching block-dangerous-git. hook::jq_fields "$INPUT" '.tool_input.command' '.tool_name' || exit 0 diff --git a/plugins/guardrails/hooks/hardcoded-path-check.sh b/plugins/guardrails/hooks/hardcoded-path-check.sh index 6ffa3aa321..9ed5936dab 100755 --- a/plugins/guardrails/hooks/hardcoded-path-check.sh +++ b/plugins/guardrails/hooks/hardcoded-path-check.sh @@ -66,7 +66,7 @@ start=${EPOCHREALTIME:-} # fault) is a loud skip the dispatcher takes once. buffer_stdin already # printed the reason to stderr. Buffering does not require jq (hook::buffer_stdin's # own JSON-completeness check is jq-optional), so it runs before the jq gate -# below — hook::require_jq needs the buffered input for its once per session +# below — hook::require jq needs the buffered input for its once per session # and agent notice scoping. hook::buffer_stdin_to INPUT || { rc=$? @@ -74,11 +74,11 @@ hook::buffer_stdin_to INPUT || { exit 0 } -# jq is required to parse the tool payload. hook::require_jq fails OPEN +# jq is required to parse the tool payload. hook::require jq fails OPEN # (advisory hooks never block over a missing prerequisite) but makes the # degraded state visible to both the user (systemMessage) and the agent # (additionalContext), once per session and agent — see docs/conventions/hook-observability/. -hook::require_jq "PreToolUse" "guardrails-hardcoded-path-check" "$INPUT" +hook::require jq "PreToolUse" "guardrails-hardcoded-path-check" "$INPUT" # Every payload field this hook can need, in ONE jq process (hook::jq_fields), # not three — a jq spawn is fork() emulation on Windows Git Bash and this guard @@ -87,7 +87,7 @@ hook::require_jq "PreToolUse" "guardrails-hardcoded-path-check" "$INPUT" # jq reads the same envelope either way, and the tool-specific choice happens # below in the shell. Failure semantics are unchanged: a missing jq or an # unparsable payload yields rc 1 here, which exits 0 exactly as the empty-TOOL -# case did — hook::require_jq above has already made the degraded state visible +# case did — hook::require jq above has already made the degraded state visible # once per session and agent. NotebookEdit's target is `notebook_path`, appended # last so the indices the MCP lane reads do not move. hook::jq_fields "$INPUT" \ diff --git a/plugins/guardrails/hooks/hook-utils.sh b/plugins/guardrails/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/guardrails/hooks/hook-utils.sh +++ b/plugins/guardrails/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/guardrails/hooks/hooks.json b/plugins/guardrails/hooks/hooks.json index 8524944da6..771e5935c7 100644 --- a/plugins/guardrails/hooks/hooks.json +++ b/plugins/guardrails/hooks/hooks.json @@ -6,8 +6,7 @@ "hooks": [ { "type": "command", - "shell": "bash", - "command": "command -v node >/dev/null 2>&1 || printf '%s\\n' '{\"systemMessage\":\"guardrails: node is not on PATH, so its guards cannot launch and enforce nothing. Install Node.js and restart Claude Code. Run /guardrails:check to verify.\",\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"WARNING: node is not on PATH, so the guards of the guardrails plugin cannot launch and enforce nothing. Tell the user. Run /guardrails:check to verify.\"}}'", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /guardrails:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /guardrails:check", "timeout": 10, "statusMessage": "Checking that node is on PATH..." } diff --git a/plugins/guardrails/hooks/require-jq-notice-isolation.test.sh b/plugins/guardrails/hooks/require-jq-notice-isolation.test.sh index e42df24a73..44cecfaba4 100755 --- a/plugins/guardrails/hooks/require-jq-notice-isolation.test.sh +++ b/plugins/guardrails/hooks/require-jq-notice-isolation.test.sh @@ -1,11 +1,11 @@ #!/usr/bin/env bash -# Cross-hook contract test: every guardrails hook's hook::require_jq call must +# Cross-hook contract test: every guardrails hook's hook::require jq call must # use a hook-specific notice_once key, not a key shared across the plugin. # # Repro-first regression for a real bug (found in independent review of #836's # fleet-adoption PR): all 9 jq-missing conversions initially passed the -# literal plugin id "guardrails" as require_jq's second argument. Since -# require_jq's notice_once key is "${plugin}-jq" (lib/hook-utils.sh), all 9 +# literal plugin id "guardrails" as hook::require's plugin argument. Since +# hook::require's notice_once key is "${plugin}-jq" (lib/hook-utils.sh), all 9 # resolved to the SAME key ("guardrails-jq") and therefore the SAME marker # file within one session — whichever guard ran first silenced the other 8 # for the rest of the session, on both channels. No single-hook *.test.sh @@ -21,7 +21,7 @@ trap 'rm -rf "$TEST_TMPDIR"' EXIT # shellcheck source=guardrails-test-helpers.sh source "$HOOK_DIR/guardrails-test-helpers.sh" -# Every hook whose source CALLS the FAIL-OPEN hook::require_jq — discovered, not +# Every hook whose source CALLS the FAIL-OPEN hook::require jq — discovered, not # hand-enumerated, so a future 10th jq-consuming hook is covered automatically. # Excludes hook-utils.sh (the function's own definition, not a call site) and # *.test.sh (which reference the name in assertions/comments, not calls) — @@ -40,15 +40,15 @@ mapfile -t JQ_HOOKS < <( hook-utils.sh | *.test.sh) continue ;; *) ;; esac - grep -lE 'hook::require_jq[[:space:]]' "$f" 2>/dev/null || true + grep -lE 'hook::require jq[[:space:]]' "$f" 2>/dev/null || true done | sort ) if ((${#JQ_HOOKS[@]} < 2)); then - bad "expected at least 2 guardrails hooks calling the fail-open hook::require_jq, found ${#JQ_HOOKS[@]} — this test needs >=2 to prove cross-hook isolation" + bad "expected at least 2 guardrails hooks calling the fail-open hook::require jq, found ${#JQ_HOOKS[@]} — this test needs >=2 to prove cross-hook isolation" fi -# --- Every hook's require_jq key is unique ----------------------------------- +# --- Every hook's require jq key is unique ----------------------------------- # Portable extraction (bash regex, not `grep -P` — BSD grep on macOS has no # Perl-regex support, and this fleet targets Windows/macOS/Linux). declare -A seen_keys=() @@ -56,30 +56,30 @@ dup_found=0 for h in "${JQ_HOOKS[@]}"; do key="" while IFS= read -r line; do - if [[ "$line" =~ hook::require_jq[[:space:]]+\"[^\"]*\"[[:space:]]+\"([^\"]+)\" ]] && + if [[ "$line" =~ hook::require[[:space:]]+jq[[:space:]]+\"[^\"]*\"[[:space:]]+\"([^\"]+)\" ]] && [[ "$line" != *"hook::require_jq_blocking"* ]]; then key="${BASH_REMATCH[1]}" break fi done <"$h" if [[ -z "$key" ]]; then - bad "$(basename "$h"): could not extract require_jq's plugin argument" + bad "$(basename "$h"): could not extract hook::require's plugin argument" continue fi if [[ -n "${seen_keys[$key]:-}" ]]; then - bad "duplicate require_jq key '$key': $(basename "$h") collides with ${seen_keys[$key]}" + bad "duplicate require jq key '$key': $(basename "$h") collides with ${seen_keys[$key]}" dup_found=1 else seen_keys[$key]="$(basename "$h")" fi done -((dup_found == 0)) && ok "every guardrails hook's require_jq key is unique (${#seen_keys[@]} distinct keys for ${#JQ_HOOKS[@]} hooks)" +((dup_found == 0)) && ok "every guardrails hook's require jq key is unique (${#seen_keys[@]} distinct keys for ${#JQ_HOOKS[@]} hooks)" # --- Runtime proof: hook::notice_once fires independently for each hook's # ACTUAL extracted key, within one shared session/data-dir ------------------- # Real jq-removal is not portably simulable (isolated bin dir without jq # cannot host bash + coreutils across Git Bash and Linux — same constraint -# secret-pattern-detection.test.sh documents), and require_jq short-circuits +# secret-pattern-detection.test.sh documents), and hook::require short-circuits # before ever calling notice_once when jq IS present (the normal test # environment). So this drives hook::notice_once directly with each hook's # real extracted key — the exact mechanism the bug lives in — rather than diff --git a/plugins/guardrails/hooks/require-jq-posture.test.sh b/plugins/guardrails/hooks/require-jq-posture.test.sh index 7ad9a73696..a931739e24 100755 --- a/plugins/guardrails/hooks/require-jq-posture.test.sh +++ b/plugins/guardrails/hooks/require-jq-posture.test.sh @@ -61,7 +61,7 @@ for f in "$HOOK_DIR"/*.sh; do hook-utils.sh | abort-boundary.sh | guardrails-test-helpers.sh | resolve-convention-pattern.sh | *.test.sh) continue ;; *) ;; esac - grep -q 'hook::require_jq' "$f" 2>/dev/null || continue + grep -qE 'hook::require(_jq_blocking| jq)' "$f" 2>/dev/null || continue if grep -qE '^MAX_COMMAND_LEN=' "$f"; then FAIL_CLOSED+=("$base") else @@ -87,8 +87,8 @@ for base in "${FAIL_CLOSED[@]}"; do else bad "$base defines MAX_COMMAND_LEN but does not call hook::require_jq_blocking — two opposite postures toward an unparsable input in one script is the defect #2146 reports" fi - if grep -qE 'hook::require_jq[[:space:]]' "$f"; then - bad "$base also calls the fail-OPEN hook::require_jq; the blocking gate must be the only jq gate in a fail-closed guard" + if grep -qE 'hook::require jq[[:space:]]' "$f"; then + bad "$base also calls the fail-OPEN hook::require jq; the blocking gate must be the only jq gate in a fail-closed guard" else ok "$base does not also carry the fail-open gate" fi diff --git a/plugins/guardrails/hooks/secret-pattern-detection.sh b/plugins/guardrails/hooks/secret-pattern-detection.sh index 8a766a7eba..f2764bd824 100755 --- a/plugins/guardrails/hooks/secret-pattern-detection.sh +++ b/plugins/guardrails/hooks/secret-pattern-detection.sh @@ -72,7 +72,7 @@ start=${EPOCHREALTIME:-} # fault) is a loud skip the dispatcher takes once. buffer_stdin already # printed the reason to stderr. Buffering does not require jq (hook::buffer_stdin's # own JSON-completeness check is jq-optional), so it runs before the jq gate -# below — hook::require_jq needs the buffered input for its once per session +# below — hook::require jq needs the buffered input for its once per session # and agent notice scoping. hook::buffer_stdin_to INPUT || { rc=$? @@ -80,11 +80,11 @@ hook::buffer_stdin_to INPUT || { exit 0 } -# jq is required to parse the tool payload. hook::require_jq fails OPEN +# jq is required to parse the tool payload. hook::require jq fails OPEN # (advisory hooks never block over a missing prerequisite) but makes the # degraded state visible to both the user (systemMessage) and the agent # (additionalContext), once per session and agent — see docs/conventions/hook-observability/. -hook::require_jq "PreToolUse" "guardrails-secret-pattern-detection" "$INPUT" +hook::require jq "PreToolUse" "guardrails-secret-pattern-detection" "$INPUT" # Every payload field this hook can need, in ONE jq process (hook::jq_fields), # not three — a jq spawn is fork() emulation on Windows Git Bash and this guard @@ -94,7 +94,7 @@ hook::require_jq "PreToolUse" "guardrails-secret-pattern-detection" "$INPUT" # below in the shell. NotebookEdit's target is `notebook_path`, appended last so # the MCP lane's indices (2, 5) do not move. Failure semantics are unchanged: a # missing jq or an unparsable payload yields rc 1 here, which exits 0 exactly as -# the empty-TOOL case did; hook::require_jq above has already made the degraded +# the empty-TOOL case did; hook::require jq above has already made the degraded # state visible once per session and agent. hook::jq_fields "$INPUT" \ '.tool_name' '.tool_input.file_path' \ diff --git a/plugins/guardrails/hooks/secret-pattern-detection.test.sh b/plugins/guardrails/hooks/secret-pattern-detection.test.sh index 451f31f72a..c763823476 100755 --- a/plugins/guardrails/hooks/secret-pattern-detection.test.sh +++ b/plugins/guardrails/hooks/secret-pattern-detection.test.sh @@ -462,12 +462,12 @@ assert_silent "kill switch off → no stderr" "$OUT" # Runtime jq-removal is not portably simulable — an isolated bin dir without jq # cannot host bash + coreutils (their DLLs / PATH) across Git Bash and Linux. # Assert the fail-open guard is present in the hook source via the shared -# hook::require_jq helper (docs/conventions/hook-observability/) — it composes +# hook::require jq helper (docs/conventions/hook-observability/) — it composes # the once per session and agent notice_once gate with the dual-channel -# (systemMessage + additionalContext) visibility notice; require_jq's own behavior is covered +# (systemMessage + additionalContext) visibility notice; hook::require's own behavior is covered # by lib/hook-utils.test.sh, not re-asserted here. HOOK_SRC=$(cat "$HOOK") -assert_contains "jq guard: uses hook::require_jq" "$HOOK_SRC" 'hook::require_jq' +assert_contains "jq guard: uses hook::require jq" "$HOOK_SRC" 'hook::require jq' # --- Allowlist path-segment anchoring (finding P5) -------------------------- # A real dependency-cache SEGMENT is exempt; a directory that merely CONTAINS the diff --git a/plugins/guardrails/hooks/skill-reference-verify.sh b/plugins/guardrails/hooks/skill-reference-verify.sh index c07fb7e37a..d13fec919c 100755 --- a/plugins/guardrails/hooks/skill-reference-verify.sh +++ b/plugins/guardrails/hooks/skill-reference-verify.sh @@ -65,7 +65,7 @@ hook::ctx_reset hook::buffer_stdin_to INPUT || exit 0 -hook::require_jq "PostToolUse" "guardrails-skill-reference-verify" "$INPUT" +hook::require jq "PostToolUse" "guardrails-skill-reference-verify" "$INPUT" FILE="" hook::read_file_path_to FILE "$INPUT" || exit 0 @@ -117,7 +117,7 @@ shopt -u nullglob # — every consumer below tests `== "true"`, which "" and "false" fail alike. # Failure semantics are unchanged: a missing jq or an unparsable payload yields # rc 1 here, which exits 0 exactly as the unmatched-TOOL case did — -# hook::require_jq above has already made the degraded state visible once per +# hook::require jq above has already made the degraded state visible once per # session and agent. hook::jq_fields "$INPUT" '.tool_name' '.tool_input.new_string' \ '.tool_input.content' '.tool_input.replace_all // false | tostring' \ diff --git a/plugins/guardrails/hooks/skill-reference-verify.test.sh b/plugins/guardrails/hooks/skill-reference-verify.test.sh index de2442d2ac..355e371a2b 100755 --- a/plugins/guardrails/hooks/skill-reference-verify.test.sh +++ b/plugins/guardrails/hooks/skill-reference-verify.test.sh @@ -822,10 +822,10 @@ assert_silent "empty stdin → no output" "$OUT" # cannot host bash + coreutils across Git Bash and Linux, the same constraint # secret-pattern-detection.test.sh and require-jq-notice-isolation.test.sh both # document. Assert the fail-open guard is present via the shared helper; -# require_jq's own behavior is covered by lib/hook-utils.test.sh, and this hook's +# hook::require's own behavior is covered by lib/hook-utils.test.sh, and this hook's # notice key is proven unique plugin-wide by require-jq-notice-isolation.test.sh. HOOK_SRC=$(cat "$HOOK") -assert_contains "jq guard: uses hook::require_jq" "$HOOK_SRC" 'hook::require_jq' +assert_contains "jq guard: uses hook::require jq" "$HOOK_SRC" 'hook::require jq' assert_contains "jq guard: hook-specific notice key" "$HOOK_SRC" 'guardrails-skill-reference-verify' # The directory comes from parameter expansion, not a `$(dirname …)` subshell: # this hook runs on every Write and Edit, and a command substitution is a fork diff --git a/plugins/guardrails/hooks/stale-path-verify.sh b/plugins/guardrails/hooks/stale-path-verify.sh index dda7ea4b9e..003e08dea4 100755 --- a/plugins/guardrails/hooks/stale-path-verify.sh +++ b/plugins/guardrails/hooks/stale-path-verify.sh @@ -69,7 +69,7 @@ hook::ctx_reset hook::buffer_stdin_to INPUT || exit 0 -hook::require_jq "PostToolUse" "guardrails-stale-path-verify" "$INPUT" +hook::require jq "PostToolUse" "guardrails-stale-path-verify" "$INPUT" FILE="" hook::read_file_path_to FILE "$INPUT" || exit 0 @@ -101,7 +101,7 @@ esac # — every consumer below tests `== "true"`, which "" and "false" fail alike. # Failure semantics are unchanged: a missing jq or an unparsable payload yields # rc 1 here, which exits 0 exactly as the unmatched-TOOL case did — -# hook::require_jq above has already made the degraded state visible once per +# hook::require jq above has already made the degraded state visible once per # session and agent. hook::jq_fields "$INPUT" '.tool_name' '.tool_input.new_string' \ '.tool_input.content' '.tool_input.replace_all // false | tostring' || exit 0 diff --git a/plugins/guardrails/hooks/stale-path-verify.test.sh b/plugins/guardrails/hooks/stale-path-verify.test.sh index 8d02090d54..f297c750e2 100755 --- a/plugins/guardrails/hooks/stale-path-verify.test.sh +++ b/plugins/guardrails/hooks/stale-path-verify.test.sh @@ -666,10 +666,10 @@ HOOK_SRC=$(cat "$HOOK") # Runtime jq-removal is not portably simulable — an isolated bin dir without jq # cannot host bash + coreutils across Git Bash and Linux, the same constraint # secret-pattern-detection.test.sh and require-jq-notice-isolation.test.sh both -# document. Assert the fail-open guard is present; require_jq's own behavior is +# document. Assert the fail-open guard is present; hook::require's own behavior is # covered by lib/hook-utils.test.sh and the notice key's plugin-wide uniqueness # by require-jq-notice-isolation.test.sh. -assert_contains "jq guard: uses hook::require_jq" "$HOOK_SRC" 'hook::require_jq' +assert_contains "jq guard: uses hook::require jq" "$HOOK_SRC" 'hook::require jq' assert_contains "jq guard: hook-specific notice key" "$HOOK_SRC" 'guardrails-stale-path-verify' # The repo root is resolved from the written file, never from the process CWD, so diff --git a/plugins/guardrails/hooks/workflow-resilience-check.sh b/plugins/guardrails/hooks/workflow-resilience-check.sh index 252be0bdfa..9672168070 100755 --- a/plugins/guardrails/hooks/workflow-resilience-check.sh +++ b/plugins/guardrails/hooks/workflow-resilience-check.sh @@ -53,22 +53,22 @@ start=${EPOCHREALTIME:-} # hook::buffer_stdin encapsulates the Win32-pipe-safe bounded fd0 read; empty # or timed-out stdin skips this advisory hook. Buffering does not require jq # (hook::buffer_stdin's own JSON-completeness check is jq-optional), so it -# runs before the jq gate below — hook::require_jq needs the buffered input +# runs before the jq gate below — hook::require jq needs the buffered input # for its once per session and agent notice scoping. hook::buffer_stdin_to INPUT || exit 0 # jq parses the tool payload and builds the additionalContext JSON. -# hook::require_jq fails OPEN (this hook never blocks) but makes the degraded +# hook::require jq fails OPEN (this hook never blocks) but makes the degraded # state visible to both the user (systemMessage) and the agent # (additionalContext), once per session and agent — see docs/conventions/hook-observability/. -hook::require_jq "PreToolUse" "guardrails-workflow-resilience-check" "$INPUT" +hook::require jq "PreToolUse" "guardrails-workflow-resilience-check" "$INPUT" # Both payload fields in ONE jq process (hook::jq_fields), not two — a jq spawn is # fork() emulation on Windows Git Bash. Failure semantics are unchanged: a missing # jq or an unparsable payload yields rc 1 here, which exits 0 exactly as the # empty-SCRIPT skip below did (both fields would have come back empty, and the # scriptPath fallback needs a non-empty SCRIPT_PATH to do anything) — -# hook::require_jq above has already made the degraded state visible once per +# hook::require jq above has already made the degraded state visible once per # session and agent. hook::jq_fields "$INPUT" '.tool_input.script' '.tool_input.scriptPath' || exit 0 SCRIPT="${HOOK_JQ_FIELDS[0]}" diff --git a/plugins/guardrails/lib/prerequisites.ps1 b/plugins/guardrails/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/guardrails/lib/prerequisites.ps1 +++ b/plugins/guardrails/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/guardrails/lib/prerequisites.sh b/plugins/guardrails/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/guardrails/lib/prerequisites.sh +++ b/plugins/guardrails/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/harness-config/.claude-plugin/plugin.json b/plugins/harness-config/.claude-plugin/plugin.json index 52b3899d70..f44036b0cb 100644 --- a/plugins/harness-config/.claude-plugin/plugin.json +++ b/plugins/harness-config/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "harness-config", - "version": "1.7.2", + "version": "1.7.3", "description": "Configuration health for a repo's Claude Code: audit (settings, .mcp.json, hooks, plugins, permission drift), audit-automation-gaps, audit-permission-grants, audit-permission-state (effective rules with provenance), draft-auto-mode-rules (prints an autoMode block), audit-instructions (instructions the model no longer needs, conflicts), audit-prompting-postures, audit-pass (one ordered, resumable pass), unhobble (strip instructions, re-add what evidence earns), and setup.", "author": { "name": "Melodic Software", diff --git a/plugins/harness-config/CHANGELOG.md b/plugins/harness-config/CHANGELOG.md index f603dbee02..13879e2756 100644 --- a/plugins/harness-config/CHANGELOG.md +++ b/plugins/harness-config/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `harness-config` plugin are documented here. Format f Versions 0.51.8 to 0.51.9 and 0.51.11 to 0.51.14 were reserved by parallel branches and never released. +## [1.7.3] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [1.7.2] - 2026-10-03 ### Changed diff --git a/plugins/harness-config/lib/prerequisites.ps1 b/plugins/harness-config/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/harness-config/lib/prerequisites.ps1 +++ b/plugins/harness-config/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/harness-config/lib/prerequisites.sh b/plugins/harness-config/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/harness-config/lib/prerequisites.sh +++ b/plugins/harness-config/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/harness-ops/.claude-plugin/plugin.json b/plugins/harness-ops/.claude-plugin/plugin.json index 2c948215d8..9b8d19e3a0 100644 --- a/plugins/harness-ops/.claude-plugin/plugin.json +++ b/plugins/harness-ops/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "harness-ops", - "version": "3.4.2", + "version": "3.5.0", "description": "Claude Code operations: audit-skill-visibility (skills the listing budget hides), inventory (all commands, skills, agents, tools, plugins), audit-install-state (~/.claude), audit-performance (slowness), audit-native-overlap (skills duplicating built-ins), observability (telemetry), known-issues (Claude bugs, status), changelog, prerequisites, check, machine-profile, plugins (update the fleet), morning-brief, lanes (background loop sessions), setup. Plus opt-in hook event logs.", "author": { "name": "Melodic Software", diff --git a/plugins/harness-ops/CHANGELOG.md b/plugins/harness-ops/CHANGELOG.md index 98175aae23..29c187029c 100644 --- a/plugins/harness-ops/CHANGELOG.md +++ b/plugins/harness-ops/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `harness-ops` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [3.5.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/harness-ops:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. +- Hooks call `hook::require jq` where they called `hook::require_jq`. + ## [3.4.2] - 2026-10-02 ### Changed diff --git a/plugins/harness-ops/hooks/hook-failure-audit.sh b/plugins/harness-ops/hooks/hook-failure-audit.sh index 6f66d73c54..64e961c989 100755 --- a/plugins/harness-ops/hooks/hook-failure-audit.sh +++ b/plugins/harness-ops/hooks/hook-failure-audit.sh @@ -96,7 +96,7 @@ START=${EPOCHREALTIME:-} # plain-string fields with the library's builtin parser — so a Stop envelope # costs no process at all, where the unfused pair cost a fork and a jq exec. # -# The fused call is also why hook::require_jq comes AFTER it rather than before: +# The fused call is also why hook::require jq comes AFTER it rather than before: # without jq the library returns an EMPTY field array and still reports success, # and reading `${HOOK_JQ_FIELDS[0]}` from it under `set -u` would kill the hook # with an unbound-variable error instead of failing open. The gate runs first, @@ -104,7 +104,7 @@ START=${EPOCHREALTIME:-} hook::buffer_stdin_to INPUT '.transcript_path' '.session_id' || exit 0 # Advisory finding -> fail open, with the standard once per session and agent notice. -hook::require_jq Stop harness-ops "$INPUT" +hook::require jq Stop harness-ops "$INPUT" # An absent field arrives as the empty string rather than as a non-zero return, # so each guard below is spelled out instead of riding on `||`. diff --git a/plugins/harness-ops/hooks/hook-utils.sh b/plugins/harness-ops/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/harness-ops/hooks/hook-utils.sh +++ b/plugins/harness-ops/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/harness-ops/hooks/hooks.json b/plugins/harness-ops/hooks/hooks.json index 603a74ee65..c458bedbb8 100644 --- a/plugins/harness-ops/hooks/hooks.json +++ b/plugins/harness-ops/hooks/hooks.json @@ -502,6 +502,16 @@ "statusMessage": "Logging the SessionStart event..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /harness-ops:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /harness-ops:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ], "Setup": [ diff --git a/plugins/harness-ops/lib/prerequisites.ps1 b/plugins/harness-ops/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/harness-ops/lib/prerequisites.ps1 +++ b/plugins/harness-ops/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/harness-ops/lib/prerequisites.sh b/plugins/harness-ops/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/harness-ops/lib/prerequisites.sh +++ b/plugins/harness-ops/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/harness-ops/skills/setup/SKILL.md b/plugins/harness-ops/skills/setup/SKILL.md index 12f929529c..f93daf1253 100644 --- a/plugins/harness-ops/skills/setup/SKILL.md +++ b/plugins/harness-ops/skills/setup/SKILL.md @@ -108,7 +108,7 @@ modify anything. through the Bash tool with `command -v node`, which does not depend on the launcher. Resolves: PASS. Does not resolve: FAIL, the hooks do not launch and record nothing; the remediation is the person installing Node.js (this skill installs nothing) and starting a fresh session. -8. **jq for the hook libraries.** `hook::require_jq` and `hook::require_jq_blocking` in +8. **jq for the hook libraries.** `hook::require jq` and `hook::require_jq_blocking` in `lib/hook-utils.sh` need `jq` on PATH. Probe it through the Bash tool with `command -v jq`. Resolves: PASS. Does not resolve: FAIL, hooks that parse their payload with jq skip their work; the remediation is the person installing jq (this skill installs nothing) and starting a fresh diff --git a/plugins/instruction-placement/.claude-plugin/plugin.json b/plugins/instruction-placement/.claude-plugin/plugin.json index 75a36b6e86..2f6eabe630 100644 --- a/plugins/instruction-placement/.claude-plugin/plugin.json +++ b/plugins/instruction-placement/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "instruction-placement", - "version": "0.20.1", + "version": "0.21.0", "description": "Routes agent instructions to the surface that loads them at the right time. audit finds conventions narrower than the always-loaded CLAUDE.md or AGENTS.md holding them, or stranded in docs Claude never loads, and proposes a destination with a validated paths glob; safety-class content is never demoted. realign applies moves item by item and regenerates an index. check verifies globs and index. migrate moves content to AGENTS.md with a CLAUDE.md shim. setup checks the index is readable.", "author": { "name": "Melodic Software", diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index 15878df614..f649ff0d77 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to the `instruction-placement` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.21.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/instruction-placement:check-prerequisites` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. +- `/instruction-placement:check-prerequisites`, a read-only report of whether node and jq resolve, through the shared Node checker. `/instruction-placement:check` keeps its rule-glob gate. +- `prerequisites.json` declaring node and jq. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.20.1] - 2026-10-02 ### Changed diff --git a/plugins/instruction-placement/README.md b/plugins/instruction-placement/README.md index 6384525f22..6e611d680c 100644 --- a/plugins/instruction-placement/README.md +++ b/plugins/instruction-placement/README.md @@ -15,6 +15,7 @@ before proposing it, and executes it behind a human gate. | `/instruction-placement:audit` | Read-only findings report | Sweeps the instruction layer and ordinary markdown, classifies candidates, emits a diffable findings artifact | | `/instruction-placement:realign` | Per-item human-gated apply | Executes accepted findings, with no blanket-approve path | | `/instruction-placement:check` | Deterministic pass/fail gate | Verifies every rule glob resolves and the always-loaded index is current | +| `/instruction-placement:check-prerequisites` | Read-only report | Reports whether the tools the plugin declares in `prerequisites.json` resolve; installs nothing | | `/instruction-placement:setup` | Verify prerequisites, report config | Confirms the index target is one Claude Code will actually read, and resolves every setting with its source | | `/instruction-placement:delta` | Read-only movement report | Re-runs the audit and reports only what changed since last time, above a noise budget, suppressing every finding the operator already declined | | `/instruction-placement:migrate` | Per-repository human-gated move | Plans and carries out a repository's move to `AGENTS.md` as the content home, with a `CLAUDE.md` shim while one is needed | diff --git a/plugins/instruction-placement/hooks/hook-utils.sh b/plugins/instruction-placement/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/instruction-placement/hooks/hook-utils.sh +++ b/plugins/instruction-placement/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/instruction-placement/hooks/hooks.json b/plugins/instruction-placement/hooks/hooks.json index 086a1f80af..fb9ce85e48 100644 --- a/plugins/instruction-placement/hooks/hooks.json +++ b/plugins/instruction-placement/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Detects a rules index drifting out of step with the rule files it indexes, after a write or edit.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /instruction-placement:check-prerequisites INDEX_DRIFT_HOOK_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /instruction-placement:check-prerequisites INDEX_DRIFT_HOOK_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "PostToolUse": [ { "matcher": "Write|Edit", diff --git a/plugins/instruction-placement/lib/prerequisites.ps1 b/plugins/instruction-placement/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/instruction-placement/lib/prerequisites.ps1 +++ b/plugins/instruction-placement/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/instruction-placement/lib/prerequisites.sh b/plugins/instruction-placement/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/instruction-placement/lib/prerequisites.sh +++ b/plugins/instruction-placement/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/instruction-placement/prerequisites.json b/plugins/instruction-placement/prerequisites.json index ae4ae02a96..052630e48d 100644 --- a/plugins/instruction-placement/prerequisites.json +++ b/plugins/instruction-placement/prerequisites.json @@ -19,7 +19,7 @@ "winget": "OpenJS.NodeJS.LTS", "apt": "nodejs" }, - "check": "/harness-ops:prerequisites" + "check": "/instruction-placement:check-prerequisites" }, { "id": "jq", @@ -42,7 +42,7 @@ "winget": "jqlang.jq", "apt": "jq" }, - "check": "/harness-ops:prerequisites" + "check": "/instruction-placement:check-prerequisites" }, { "id": "curl", @@ -63,7 +63,7 @@ "winget": "cURL.cURL", "apt": "curl" }, - "check": "/harness-ops:prerequisites" + "check": "/instruction-placement:check-prerequisites" } ] } diff --git a/plugins/instruction-placement/skills/check-prerequisites/SKILL.md b/plugins/instruction-placement/skills/check-prerequisites/SKILL.md new file mode 100644 index 0000000000..f17fb2b27f --- /dev/null +++ b/plugins/instruction-placement/skills/check-prerequisites/SKILL.md @@ -0,0 +1,38 @@ +--- +description: "Read-only report of whether the external tools the instruction-placement plugin declares in prerequisites.json resolve, through the shared Node checker. /instruction-placement:check gates rule globs; this skill checks the plugin's own tools. Use when a hook notice says node or jq is missing, or before assuming the rules index drift hook ran. Does not install." +user-invocable: true +disable-model-invocation: false +metadata: + workflow-stage: anytime + summary: Report whether node and jq resolve for instruction-placement. Never installs. +--- + +## Purpose + +Run the read-only check. Do not install anything, and do not edit `prerequisites.json`. + +The plugin's `check` skill already means the rule-glob gate, so the prerequisites check has its own name. A hook notice names this skill. + +## Check + +Run the shared checker through its stub, which reports a missing `node` instead of failing to start: + +```bash +sh "${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh" check "${CLAUDE_PLUGIN_ROOT}" --data-dir "${CLAUDE_PLUGIN_DATA}" +``` + +Where there is no `sh` (Windows without Git Bash), run the PowerShell stub with the same arguments instead: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1" check "${CLAUDE_PLUGIN_ROOT}" --data-dir "${CLAUDE_PLUGIN_DATA}" +``` + +Report the PASS/FAIL rows as a table. On a FAIL, give the install hints the checker printed. Stop. + +## Next + +A passing check has no successor. For the rule-glob gate, run `/instruction-placement:check`. + +## Gotchas + +This skill does not install. A hook notice is not permission to install Node.js or jq. diff --git a/plugins/instruction-placement/skills/check-prerequisites/evals/evals.json b/plugins/instruction-placement/skills/check-prerequisites/evals/evals.json new file mode 100644 index 0000000000..0d6a06e825 --- /dev/null +++ b/plugins/instruction-placement/skills/check-prerequisites/evals/evals.json @@ -0,0 +1,29 @@ +{ + "skill_name": "check-prerequisites", + "evals": [ + { + "id": 1, + "name": "hook-notice-runs-the-checker", + "prompt": "A hook notice says node or jq is missing for instruction-placement. What is missing?", + "expected_output": "Runs /instruction-placement:check-prerequisites, reports each PASS/FAIL row with the install hint the checker printed, and stops. Installs nothing.", + "files": [], + "expectations": [ + "Runs `lib/prerequisites.sh check` or the PowerShell stub with the same arguments", + "Reports every FAIL row with the install hint the checker printed", + "Does not install Node.js or jq and does not edit prerequisites.json" + ] + }, + { + "id": 2, + "name": "passing-check-names-the-rule-gate", + "prompt": "/instruction-placement:check-prerequisites", + "expected_output": "When every row passes, reports the rows and stops. The rule-glob gate stays /instruction-placement:check.", + "files": [], + "expectations": [ + "Reports the rows the checker printed", + "On all PASS, does not install anything", + "Does not treat this skill as the rule-glob gate" + ] + } + ] +} diff --git a/plugins/kindle-dedrm/.claude-plugin/plugin.json b/plugins/kindle-dedrm/.claude-plugin/plugin.json index 01d7690f75..24c7ee41a7 100644 --- a/plugins/kindle-dedrm/.claude-plugin/plugin.json +++ b/plugins/kindle-dedrm/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kindle-dedrm", - "version": "0.8.0", + "version": "0.8.1", "description": "Manage the Kindle for PC 2.8.0 + Calibre DeDRM workflow for personal-use ebook DRM removal on books the user owns (Windows only). Action router with setup, sync, update, cleanup, and status, each state mutation paired with a documented compensating reversal.", "author": { "name": "Melodic Software", diff --git a/plugins/kindle-dedrm/CHANGELOG.md b/plugins/kindle-dedrm/CHANGELOG.md index 4d83e02e67..f306be799c 100644 --- a/plugins/kindle-dedrm/CHANGELOG.md +++ b/plugins/kindle-dedrm/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `kindle-dedrm` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.8.0] - 2026-10-02 ### Added diff --git a/plugins/kindle-dedrm/lib/prerequisites.ps1 b/plugins/kindle-dedrm/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/kindle-dedrm/lib/prerequisites.ps1 +++ b/plugins/kindle-dedrm/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/kindle-dedrm/lib/prerequisites.sh b/plugins/kindle-dedrm/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/kindle-dedrm/lib/prerequisites.sh +++ b/plugins/kindle-dedrm/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/knowledge/.claude-plugin/plugin.json b/plugins/knowledge/.claude-plugin/plugin.json index 087271a663..22d419169d 100644 --- a/plugins/knowledge/.claude-plugin/plugin.json +++ b/plugins/knowledge/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "knowledge", - "version": "0.19.1", + "version": "0.19.2", "description": "Ingests external knowledge into synthesized artifacts: book-distill (PDF or EPUB into author-attributed reference files), video-digest (one YouTube or X video: transcript, links, repo applicability), course-digest (Dometrain and Teachable courses into recommendations), docpage-digest (one documentation page into a verified knowledge slice), and map-corpus (a multi-resource corpus into a classified link map and an approved docpage-digest queue). setup sets where artifacts land.", "author": { "name": "Melodic Software", diff --git a/plugins/knowledge/CHANGELOG.md b/plugins/knowledge/CHANGELOG.md index 3346a8ab6c..f4e9ae665e 100644 --- a/plugins/knowledge/CHANGELOG.md +++ b/plugins/knowledge/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the `knowledge` plugin are recorded here. The `version` i `.claude-plugin/plugin.json` is the delivery vehicle. A consumer receives a change only after that version increases. +## [0.19.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.19.1] - 2026-10-03 ### Fixed diff --git a/plugins/knowledge/lib/prerequisites.ps1 b/plugins/knowledge/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/knowledge/lib/prerequisites.ps1 +++ b/plugins/knowledge/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/knowledge/lib/prerequisites.sh b/plugins/knowledge/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/knowledge/lib/prerequisites.sh +++ b/plugins/knowledge/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/machine-health/.claude-plugin/plugin.json b/plugins/machine-health/.claude-plugin/plugin.json index dfa5cd13b4..9ac9ef30a7 100644 --- a/plugins/machine-health/.claude-plugin/plugin.json +++ b/plugins/machine-health/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "machine-health", - "version": "0.15.0", + "version": "0.15.1", "description": "Workstation health audit: OS-specific checks (disk, OS updates, security posture, CISA KEV correlation) run from a versioned catalog with trend-aware severity, approval-gated remediations, and dated markdown reports. Windows fully implemented; macOS/Linux scaffolded (report UNKNOWN and stop). Machine state persists in the plugin data directory; the report directory and check catalog are configurable.", "author": { "name": "Melodic Software", diff --git a/plugins/machine-health/CHANGELOG.md b/plugins/machine-health/CHANGELOG.md index 9de9865fa0..250c08020a 100644 --- a/plugins/machine-health/CHANGELOG.md +++ b/plugins/machine-health/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `machine-health` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.15.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.15.0] - 2026-10-02 ### Added diff --git a/plugins/machine-health/lib/prerequisites.ps1 b/plugins/machine-health/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/machine-health/lib/prerequisites.ps1 +++ b/plugins/machine-health/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/machine-health/lib/prerequisites.sh b/plugins/machine-health/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/machine-health/lib/prerequisites.sh +++ b/plugins/machine-health/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/markdown-format/.claude-plugin/plugin.json b/plugins/markdown-format/.claude-plugin/plugin.json index b78797566a..dcae4c2e34 100644 --- a/plugins/markdown-format/.claude-plugin/plugin.json +++ b/plugins/markdown-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "markdown-format", - "version": "0.11.98", + "version": "0.12.0", "description": "Auto-format and lint Markdown on edit via markdownlint-cli2, only in repos that carry their own markdownlint config.", "author": { "name": "Melodic Software", diff --git a/plugins/markdown-format/CHANGELOG.md b/plugins/markdown-format/CHANGELOG.md index 518bee07c2..40807c0ed3 100644 --- a/plugins/markdown-format/CHANGELOG.md +++ b/plugins/markdown-format/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `markdown-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.12.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/markdown-format:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. +- Hooks call `hook::require jq` where they called `hook::require_jq`. + ## [0.11.98] - 2026-10-02 ### Changed diff --git a/plugins/markdown-format/README.md b/plugins/markdown-format/README.md index 6257286537..1fe695e748 100644 --- a/plugins/markdown-format/README.md +++ b/plugins/markdown-format/README.md @@ -167,6 +167,12 @@ and it honors `markdown_format_enabled`. `/markdown-format:check` is the read-only check that notice names. The probe does not look for a markdownlint config, so it can report in a repository that has none. +`jq` is deliberately absent from `prerequisites.json`. That manifest drives the +session-start probe, which does not consult the per-repo config opt-in, while the +missing-`jq` notice comes only from the per-edit hook after its opt-in pre-check +(`markdown-format.sh`, `hook::require jq` after the config walk). Listing `jq` +would announce it in repositories that never opted in. + Telemetry timing uses `EPOCHREALTIME` (Bash 5.0+); on older Bash the telemetry envelope is skipped while formatting still runs. diff --git a/plugins/markdown-format/hooks/hook-utils.sh b/plugins/markdown-format/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/markdown-format/hooks/hook-utils.sh +++ b/plugins/markdown-format/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/markdown-format/hooks/hooks.json b/plugins/markdown-format/hooks/hooks.json index f2547a1a0d..289b2b3e06 100644 --- a/plugins/markdown-format/hooks/hooks.json +++ b/plugins/markdown-format/hooks/hooks.json @@ -47,6 +47,16 @@ "statusMessage": "Checking markdownlint-cli2..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /markdown-format:check MARKDOWN_FORMAT_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /markdown-format:check MARKDOWN_FORMAT_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/markdown-format/hooks/markdown-format.sh b/plugins/markdown-format/hooks/markdown-format.sh index f693bc4ace..11a35793eb 100755 --- a/plugins/markdown-format/hooks/markdown-format.sh +++ b/plugins/markdown-format/hooks/markdown-format.sh @@ -64,7 +64,7 @@ emit_skipped() { # gated only by the markdown_format_enabled kill switch) still reports a # missing markdownlint-cli2 in a repo that never opted in; only this per-edit # hook is opt-in gated. The pre-check below keeps a repo without a config from -# seeing the jq notice, since a gate that ran only after hook::require_jq would +# seeing the jq notice, since a gate that ran only after hook::require jq would # still nag about a prerequisite for a hook that repository has not enabled. # # Candidates are exactly the files markdownlint-cli2 documents as automatically diff --git a/plugins/markdown-format/hooks/markdown-format.test.sh b/plugins/markdown-format/hooks/markdown-format.test.sh index 488015b2ec..48b5e0a08e 100755 --- a/plugins/markdown-format/hooks/markdown-format.test.sh +++ b/plugins/markdown-format/hooks/markdown-format.test.sh @@ -1939,7 +1939,7 @@ fi # shellcheck disable=SC2016 # the ${CLAUDE_PLUGIN_ROOT} placeholders are literal manifest text PROBE_ARGS_WANT='[["${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs","probe","${CLAUDE_PLUGIN_ROOT}","--run-if-unset-or-true","MARKDOWN_FORMAT_ENABLED"]]' if command -v jq >/dev/null 2>&1 && [[ -f "$HOOKS_JSON" ]]; then - PROBE_ARGS_GOT="$(jq -c '[.hooks.SessionStart[]?.hooks[]?.args]' "$HOOKS_JSON")" + PROBE_ARGS_GOT="$(jq -c '[.hooks.SessionStart[]?.hooks[]? | select(.args) | .args]' "$HOOKS_JSON")" if [[ "$PROBE_ARGS_GOT" == "$PROBE_ARGS_WANT" ]]; then ok "hooks.json: SessionStart probe is gated by --run-if-unset-or-true MARKDOWN_FORMAT_ENABLED" else diff --git a/plugins/markdown-format/lib/prerequisites.ps1 b/plugins/markdown-format/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/markdown-format/lib/prerequisites.ps1 +++ b/plugins/markdown-format/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/markdown-format/lib/prerequisites.sh b/plugins/markdown-format/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/markdown-format/lib/prerequisites.sh +++ b/plugins/markdown-format/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/mcp-tools/.claude-plugin/plugin.json b/plugins/mcp-tools/.claude-plugin/plugin.json index a3f3e001a4..ada28760e8 100644 --- a/plugins/mcp-tools/.claude-plugin/plugin.json +++ b/plugins/mcp-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-tools", - "version": "0.6.0", + "version": "0.6.1", "description": "Two MCP audits. audit scores the tool definitions of a server you build against MCP-specification, Anthropic tool-design, and Claude-Code client criteria in a per-tool PASS/WARN/FAIL scorecard (Python, TypeScript, .NET). audit-posture inventories the MCP servers your Claude Code configuration runs and flags supply-chain risks such as floating package versions, without running any server.", "author": { "name": "Melodic Software", diff --git a/plugins/mcp-tools/CHANGELOG.md b/plugins/mcp-tools/CHANGELOG.md index 41b8a2c352..574d9630cb 100644 --- a/plugins/mcp-tools/CHANGELOG.md +++ b/plugins/mcp-tools/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `mcp-tools` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.6.0] - 2026-10-02 ### Added diff --git a/plugins/mcp-tools/lib/prerequisites.ps1 b/plugins/mcp-tools/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/mcp-tools/lib/prerequisites.ps1 +++ b/plugins/mcp-tools/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/mcp-tools/lib/prerequisites.sh b/plugins/mcp-tools/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/mcp-tools/lib/prerequisites.sh +++ b/plugins/mcp-tools/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/miro/.claude-plugin/plugin.json b/plugins/miro/.claude-plugin/plugin.json index f767f0216f..9eda60fc97 100644 --- a/plugins/miro/.claude-plugin/plugin.json +++ b/plugins/miro/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "miro", - "version": "0.6.1", + "version": "0.6.2", "description": "Miro board management over the Model Context Protocol: create and manage boards, sticky notes, shapes, frames, connectors, and tags for EventStorming, brainstorming, and diagramming. Ships a local stdio MCP server that installs its pinned npm dependencies on first launch (needs Node.js 24+ and npm); installs disabled, so opt in. The server starts without a Miro API token; until one is set, each tool call explains how to set it.", "author": { "name": "Melodic Software", diff --git a/plugins/miro/CHANGELOG.md b/plugins/miro/CHANGELOG.md index 22fbc691d0..f8d572a100 100644 --- a/plugins/miro/CHANGELOG.md +++ b/plugins/miro/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `miro` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.6.1] - 2026-10-03 ### Changed diff --git a/plugins/miro/lib/prerequisites.ps1 b/plugins/miro/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/miro/lib/prerequisites.ps1 +++ b/plugins/miro/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/miro/lib/prerequisites.sh b/plugins/miro/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/miro/lib/prerequisites.sh +++ b/plugins/miro/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/multi-agent/.claude-plugin/plugin.json b/plugins/multi-agent/.claude-plugin/plugin.json index e2ad1d24b9..2d8b74570a 100644 --- a/plugins/multi-agent/.claude-plugin/plugin.json +++ b/plugins/multi-agent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "multi-agent", - "version": "0.3.3", + "version": "0.4.0", "description": "Workflow and model-routing guidance for multi-agent work: assess (workflow, subagent or single context), route (the role map a workflow script reads from args, keeping fan-out stages off a frontier model), audit-defaults (rechecks bundled defaults against upstream, or sweeps the repo's model and workflow guidance, via a read-only drift-audit workflow), check (whether node resolves and the fetch gate is registered), and setup (writes a user, team or local layer after a preview and a yes).", "author": { "name": "Melodic Software", diff --git a/plugins/multi-agent/CHANGELOG.md b/plugins/multi-agent/CHANGELOG.md index 6cfbf451a8..6d73864603 100644 --- a/plugins/multi-agent/CHANGELOG.md +++ b/plugins/multi-agent/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to the `multi-agent` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.4.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/multi-agent:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + ## [0.3.3] - 2026-10-02 ### Changed diff --git a/plugins/multi-agent/hooks/hooks.json b/plugins/multi-agent/hooks/hooks.json index d17fdaf553..764cd8db3d 100644 --- a/plugins/multi-agent/hooks/hooks.json +++ b/plugins/multi-agent/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Inside a multi-agent:drift-checker subagent, denies any WebFetch that is not an https URL on a first-party docs host with no query string. Every other agent and the main thread pass through.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /multi-agent:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /multi-agent:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "PreToolUse": [ { "matcher": "WebFetch", diff --git a/plugins/multi-agent/lib/prerequisites.ps1 b/plugins/multi-agent/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/multi-agent/lib/prerequisites.ps1 +++ b/plugins/multi-agent/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/multi-agent/lib/prerequisites.sh b/plugins/multi-agent/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/multi-agent/lib/prerequisites.sh +++ b/plugins/multi-agent/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/pixel-art/.claude-plugin/plugin.json b/plugins/pixel-art/.claude-plugin/plugin.json index 51f84a0c15..32a8917dfc 100644 --- a/plugins/pixel-art/.claude-plugin/plugin.json +++ b/plugins/pixel-art/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "pixel-art", - "version": "0.6.0", + "version": "0.6.1", "description": "Creates pixel art with no external tools: sprites (sprite), animation sprite sheets for RPG Maker MZ, Godot, PICO-8, or plain strips with Aseprite-style frame data and GIF previews (animate), tilesets and parallax (tileset), UI skins and bitmap fonts (ui), effect sheets (vfx), and animated scenes as one HTML file (scene). Specs are palette-locked; a humanoid kit and a Python stdlib renderer write PNG, GIF, and frame data with a render-review loop. Optional Aseprite adapter.", "author": { "name": "Melodic Software", diff --git a/plugins/pixel-art/CHANGELOG.md b/plugins/pixel-art/CHANGELOG.md index 29fc2a1df9..c51c9b8c05 100644 --- a/plugins/pixel-art/CHANGELOG.md +++ b/plugins/pixel-art/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `pixel-art` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.6.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.6.0] - 2026-10-02 ### Added diff --git a/plugins/pixel-art/lib/prerequisites.ps1 b/plugins/pixel-art/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/pixel-art/lib/prerequisites.ps1 +++ b/plugins/pixel-art/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/pixel-art/lib/prerequisites.sh b/plugins/pixel-art/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/pixel-art/lib/prerequisites.sh +++ b/plugins/pixel-art/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/planning/.claude-plugin/plugin.json b/plugins/planning/.claude-plugin/plugin.json index 5729f3bd89..49dfb6e307 100644 --- a/plugins/planning/.claude-plugin/plugin.json +++ b/plugins/planning/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "planning", - "version": "0.65.2", + "version": "0.65.3", "userConfig": { "surface": { "type": "string", diff --git a/plugins/planning/CHANGELOG.md b/plugins/planning/CHANGELOG.md index 532901eda7..562d2b7a98 100644 --- a/plugins/planning/CHANGELOG.md +++ b/plugins/planning/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `planning` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.65.3] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.65.2] - 2026-10-02 ### Fixed diff --git a/plugins/planning/lib/prerequisites.ps1 b/plugins/planning/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/planning/lib/prerequisites.ps1 +++ b/plugins/planning/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/planning/lib/prerequisites.sh b/plugins/planning/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/planning/lib/prerequisites.sh +++ b/plugins/planning/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/playbooks/.claude-plugin/plugin.json b/plugins/playbooks/.claude-plugin/plugin.json index bdd749a930..6d73ce74a0 100644 --- a/plugins/playbooks/.claude-plugin/plugin.json +++ b/plugins/playbooks/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "playbooks", - "version": "0.19.1", + "version": "0.19.2", "description": "Doctrine and knowledge playbooks as on-demand skills: boris (Boris Cherny's Claude Code workflow tips, howborisusesclaudecode.com), skill-authoring (Anthropic's internal skill-authoring playbook), fable-5 (Claude Fable 5's operating doctrine, self-authored), and repo-sweep (runs a catalog of hygiene skills through a repository one commit per step). boris and skill-authoring vendor a verbatim upstream baseline; /playbooks:update drift-checks and syncs them (maintainers).", "author": { "name": "Melodic Software", diff --git a/plugins/playbooks/CHANGELOG.md b/plugins/playbooks/CHANGELOG.md index 90d3037d82..8616c0f45c 100644 --- a/plugins/playbooks/CHANGELOG.md +++ b/plugins/playbooks/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to the `playbooks` plugin are recorded here. The `version` i `.claude-plugin/plugin.json` is the delivery vehicle. A consumer receives a change only after that version increases. +## [0.19.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.19.1] - 2026-10-03 ### Changed diff --git a/plugins/playbooks/lib/prerequisites.ps1 b/plugins/playbooks/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/playbooks/lib/prerequisites.ps1 +++ b/plugins/playbooks/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/playbooks/lib/prerequisites.sh b/plugins/playbooks/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/playbooks/lib/prerequisites.sh +++ b/plugins/playbooks/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/playwright/.claude-plugin/plugin.json b/plugins/playwright/.claude-plugin/plugin.json index e0ab97bf8d..81a3c0896c 100644 --- a/plugins/playwright/.claude-plugin/plugin.json +++ b/plugins/playwright/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "playwright", - "version": "0.8.5", + "version": "0.8.6", "description": "Live E2E browser automation via Microsoft's @playwright/cli: named sessions, accessibility-ref snapshots, click/fill by ref, screenshots, console and network capture, mocking, tracing, video, and auth state, with artifacts written to disk so only paths enter context, plus a vendored upstream baseline and maintainer drift-check update flow.", "author": { "name": "Melodic Software", diff --git a/plugins/playwright/CHANGELOG.md b/plugins/playwright/CHANGELOG.md index 56c8e32298..ad0913e8b1 100644 --- a/plugins/playwright/CHANGELOG.md +++ b/plugins/playwright/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `playwright` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.8.6] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.8.5] - 2026-10-03 ### Changed diff --git a/plugins/playwright/lib/prerequisites.ps1 b/plugins/playwright/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/playwright/lib/prerequisites.ps1 +++ b/plugins/playwright/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/playwright/lib/prerequisites.sh b/plugins/playwright/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/playwright/lib/prerequisites.sh +++ b/plugins/playwright/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/plugin-quality/.claude-plugin/plugin.json b/plugins/plugin-quality/.claude-plugin/plugin.json index 8816f28845..cbdebeb489 100644 --- a/plugins/plugin-quality/.claude-plugin/plugin.json +++ b/plugins/plugin-quality/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "plugin-quality", - "version": "0.13.2", + "version": "0.13.3", "description": "Post-use behavioral audit (plugin-quality:audit) of any Claude Code plugin skill, agent, hook, command, or config you have used. Six steps: capture evidence; map behavior in a fresh subagent that records errors, improvements, and quality-of-life items against standards; a blindspot pass; an interactive contract lock; review with a research gate on each finding's claims and fixes; and a confirmed work item for the maintainers. Uses context-guard snapshots when present.", "author": { "name": "Melodic Software", diff --git a/plugins/plugin-quality/CHANGELOG.md b/plugins/plugin-quality/CHANGELOG.md index 08bae93a34..131f9f5e78 100644 --- a/plugins/plugin-quality/CHANGELOG.md +++ b/plugins/plugin-quality/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `plugin-quality` plugin. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.13.3] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.13.2] - 2026-10-03 ### Changed diff --git a/plugins/plugin-quality/lib/prerequisites.ps1 b/plugins/plugin-quality/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/plugin-quality/lib/prerequisites.ps1 +++ b/plugins/plugin-quality/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/plugin-quality/lib/prerequisites.sh b/plugins/plugin-quality/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/plugin-quality/lib/prerequisites.sh +++ b/plugins/plugin-quality/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/powershell-format/.claude-plugin/plugin.json b/plugins/powershell-format/.claude-plugin/plugin.json index 87522b9b5f..73b6cafd9c 100644 --- a/plugins/powershell-format/.claude-plugin/plugin.json +++ b/plugins/powershell-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "powershell-format", - "version": "0.9.15", + "version": "0.10.0", "description": "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.", "author": { "name": "Melodic Software", diff --git a/plugins/powershell-format/CHANGELOG.md b/plugins/powershell-format/CHANGELOG.md index 9fff45c58a..e9a3e5c996 100644 --- a/plugins/powershell-format/CHANGELOG.md +++ b/plugins/powershell-format/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `powershell-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.10.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/powershell-format:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.9.15] - 2026-10-02 ### Changed diff --git a/plugins/powershell-format/hooks/hook-utils.sh b/plugins/powershell-format/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/powershell-format/hooks/hook-utils.sh +++ b/plugins/powershell-format/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/powershell-format/hooks/hooks.json b/plugins/powershell-format/hooks/hooks.json index f07c4a2ac2..67c4e424c6 100644 --- a/plugins/powershell-format/hooks/hooks.json +++ b/plugins/powershell-format/hooks/hooks.json @@ -58,6 +58,16 @@ "statusMessage": "Checking pwsh..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /powershell-format:check POWERSHELL_FORMAT_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /powershell-format:check POWERSHELL_FORMAT_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/powershell-format/lib/prerequisites.ps1 b/plugins/powershell-format/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/powershell-format/lib/prerequisites.ps1 +++ b/plugins/powershell-format/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/powershell-format/lib/prerequisites.sh b/plugins/powershell-format/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/powershell-format/lib/prerequisites.sh +++ b/plugins/powershell-format/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index f11dfbb344..c14e1ec1a5 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "rate-limit-guard", - "version": "0.10.10", + "version": "0.11.0", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index bc8215a01a..eac8bf8ace 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.11.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/rate-limit-guard:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.10.10] - 2026-10-02 ### Changed diff --git a/plugins/rate-limit-guard/hooks/hook-utils.sh b/plugins/rate-limit-guard/hooks/hook-utils.sh index 4327545cef..24f5937c20 100755 --- a/plugins/rate-limit-guard/hooks/hook-utils.sh +++ b/plugins/rate-limit-guard/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/rate-limit-guard/hooks/hooks.json b/plugins/rate-limit-guard/hooks/hooks.json index 2b1d636b30..510c3e7571 100644 --- a/plugins/rate-limit-guard/hooks/hooks.json +++ b/plugins/rate-limit-guard/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Records a stop caused by an API rate limit, so the pause is visible rather than looking like a finished turn.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /rate-limit-guard:check RATE_LIMIT_GUARD_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /rate-limit-guard:check RATE_LIMIT_GUARD_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "StopFailure": [ { "matcher": "rate_limit", diff --git a/plugins/rate-limit-guard/lib/prerequisites.ps1 b/plugins/rate-limit-guard/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/rate-limit-guard/lib/prerequisites.ps1 +++ b/plugins/rate-limit-guard/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/rate-limit-guard/lib/prerequisites.sh b/plugins/rate-limit-guard/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/rate-limit-guard/lib/prerequisites.sh +++ b/plugins/rate-limit-guard/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json index 2977f54cea..c980f24358 100644 --- a/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-fleet-hygiene/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "repo-fleet-hygiene", - "version": "0.28.0", + "version": "0.28.1", "description": "Cross-repository Git/GitHub fleet discovery, evidence rollup, a gated apply verb, and a sync verb that fast-forwards canonical checkouts onto the remote default branch. Audit stays read-only. apply and sync mutate only with --apply plus interactive confirmation or --yes.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-fleet-hygiene/CHANGELOG.md b/plugins/repo-fleet-hygiene/CHANGELOG.md index e392c31034..dbb7efd02a 100644 --- a/plugins/repo-fleet-hygiene/CHANGELOG.md +++ b/plugins/repo-fleet-hygiene/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to `repo-fleet-hygiene` are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.28.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.28.0] - 2026-10-02 ### Added diff --git a/plugins/repo-fleet-hygiene/lib/prerequisites.ps1 b/plugins/repo-fleet-hygiene/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/repo-fleet-hygiene/lib/prerequisites.ps1 +++ b/plugins/repo-fleet-hygiene/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/repo-fleet-hygiene/lib/prerequisites.sh b/plugins/repo-fleet-hygiene/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/repo-fleet-hygiene/lib/prerequisites.sh +++ b/plugins/repo-fleet-hygiene/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/repo-hygiene/.claude-plugin/plugin.json b/plugins/repo-hygiene/.claude-plugin/plugin.json index 2b8822c668..cae1c0206a 100644 --- a/plugins/repo-hygiene/.claude-plugin/plugin.json +++ b/plugins/repo-hygiene/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "repo-hygiene", - "version": "0.19.1", + "version": "0.19.2", "description": "Repo hygiene action-router: /repo-hygiene:clean sweeps reclaimable caches, build artifacts, and stale git metadata, and can realign the working tree to a fresh-pull state, dry-run-first, with destructive tiers gated behind explicit confirmation and a session-scoped destructive-command guard. Ecosystem targets are detected at runtime; secrets, runtime dependencies, and skill data are preserved by default.", "author": { "name": "Melodic Software", diff --git a/plugins/repo-hygiene/CHANGELOG.md b/plugins/repo-hygiene/CHANGELOG.md index adaeab89b7..cf67f7f715 100644 --- a/plugins/repo-hygiene/CHANGELOG.md +++ b/plugins/repo-hygiene/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `repo-hygiene` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.19.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.19.1] - 2026-10-02 ### Changed diff --git a/plugins/repo-hygiene/lib/prerequisites.ps1 b/plugins/repo-hygiene/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/repo-hygiene/lib/prerequisites.ps1 +++ b/plugins/repo-hygiene/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/repo-hygiene/lib/prerequisites.sh b/plugins/repo-hygiene/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/repo-hygiene/lib/prerequisites.sh +++ b/plugins/repo-hygiene/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/retro-audio/.claude-plugin/plugin.json b/plugins/retro-audio/.claude-plugin/plugin.json index 80bd0de775..a891f613bb 100644 --- a/plugins/retro-audio/.claude-plugin/plugin.json +++ b/plugins/retro-audio/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "retro-audio", - "version": "0.2.0", + "version": "0.2.1", "description": "Renders retro sound effects and short chiptune loops to WAV with the Python standard library only: an sfxr-style parameter model and an MML subset with Game Boy and NES pulse duties and channel limits for Game Boy, NES, and PICO-8 (four channels, one noise part). Another plugin can play the WAV; this one does not read that plugin's files.", "author": { "name": "Melodic Software", diff --git a/plugins/retro-audio/CHANGELOG.md b/plugins/retro-audio/CHANGELOG.md index 436bc0ca25..c1112cbb4b 100644 --- a/plugins/retro-audio/CHANGELOG.md +++ b/plugins/retro-audio/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `retro-audio` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.2.1] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.2.0] - 2026-10-02 ### Added diff --git a/plugins/retro-audio/lib/prerequisites.ps1 b/plugins/retro-audio/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/retro-audio/lib/prerequisites.ps1 +++ b/plugins/retro-audio/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/retro-audio/lib/prerequisites.sh b/plugins/retro-audio/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/retro-audio/lib/prerequisites.sh +++ b/plugins/retro-audio/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/review/.claude-plugin/plugin.json b/plugins/review/.claude-plugin/plugin.json index 35dec1c2d3..6fdf4b0d5c 100644 --- a/plugins/review/.claude-plugin/plugin.json +++ b/plugins/review/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "review", - "version": "0.37.1", + "version": "0.37.2", "description": "Code-review toolkit: six reviewer agents, read-only over the reviewed code (code, security, architecture, doc drift, build/test/lint, CI-log audit), plus orchestration skills for the quality gate, fan-out, and enforceability audit (/review:audit-enforceability), an offered HTML pull-request explainer (/review:pr-explainer), a fan-out sweep workflow (/review:fanout-sweep), and CI lane commands (/review:code-review, /review:security-review) for org reusable workflows.", "author": { "name": "Melodic Software", diff --git a/plugins/review/CHANGELOG.md b/plugins/review/CHANGELOG.md index ae369159c9..35268e0cd4 100644 --- a/plugins/review/CHANGELOG.md +++ b/plugins/review/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `review` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.37.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [0.37.1] - 2026-10-02 ### Changed diff --git a/plugins/review/lib/prerequisites.ps1 b/plugins/review/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/review/lib/prerequisites.ps1 +++ b/plugins/review/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/review/lib/prerequisites.sh b/plugins/review/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/review/lib/prerequisites.sh +++ b/plugins/review/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/ruff-format/.claude-plugin/plugin.json b/plugins/ruff-format/.claude-plugin/plugin.json index de682f2c3e..b16556946b 100644 --- a/plugins/ruff-format/.claude-plugin/plugin.json +++ b/plugins/ruff-format/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ruff-format", - "version": "0.8.15", + "version": "0.9.0", "description": "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.", "author": { "name": "Melodic Software", diff --git a/plugins/ruff-format/CHANGELOG.md b/plugins/ruff-format/CHANGELOG.md index a0f9e84766..8bed4d257d 100644 --- a/plugins/ruff-format/CHANGELOG.md +++ b/plugins/ruff-format/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to the `ruff-format` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.9.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/ruff-format:check` and is silenced by the plugin's kill switch. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. + ## [0.8.15] - 2026-10-02 ### Changed diff --git a/plugins/ruff-format/hooks/hook-utils.sh b/plugins/ruff-format/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/ruff-format/hooks/hook-utils.sh +++ b/plugins/ruff-format/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/ruff-format/hooks/hooks.json b/plugins/ruff-format/hooks/hooks.json index 0f8d7b0e9f..bd8d15f859 100644 --- a/plugins/ruff-format/hooks/hooks.json +++ b/plugins/ruff-format/hooks/hooks.json @@ -47,6 +47,16 @@ "statusMessage": "Checking ruff..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /ruff-format:check RUFF_FORMAT_ENABLED; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /ruff-format:check RUFF_FORMAT_ENABLED", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/ruff-format/hooks/ruff-format.test.sh b/plugins/ruff-format/hooks/ruff-format.test.sh index 454a255e35..4757ab6306 100755 --- a/plugins/ruff-format/hooks/ruff-format.test.sh +++ b/plugins/ruff-format/hooks/ruff-format.test.sh @@ -663,10 +663,12 @@ EXPECTED_IF="$(printf '%s\n' "$SCRIPT_EXTS" | sed 's/.*/Edit(&)/' | tr '\n' ' ') EXPECTED_IF="${EXPECTED_IF% }" EXPECTED_COUNT="$(printf '%s\n' "$SCRIPT_EXTS" | grep -c .)" if command -v jq >/dev/null 2>&1 && [[ -f "$HOOKS_JSON" && -n "$BEGIN_LINE" && "$EXPECTED_COUNT" -gt 0 ]]; then + # The node-notice SessionStart row is filtered out here and pinned fleet-wide by + # scripts/node-notice-rows.test.sh. # The one row outside this gate is the SessionStart prerequisite probe, exec # form behind the ruff_format_enabled launcher gate, which is asserted on its # own here. - ALL_HANDLERS="$(jq -c '[.hooks | to_entries[] | .key as $ev | .value[]? | .matcher as $m | .hooks[]? | . + {event: $ev, matcher: ($m // "(none)")}]' "$HOOKS_JSON")" + ALL_HANDLERS="$(jq -c '[.hooks | to_entries[] | .key as $ev | .value[]? | .matcher as $m | .hooks[]? | select((.command // "") | contains("node-notice") | not) | . + {event: $ev, matcher: ($m // "(none)")}]' "$HOOKS_JSON")" PROBE_COUNT="$(jq -c --arg checker '${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs' --arg root '${CLAUDE_PLUGIN_ROOT}' '[.[] | select(.event == "SessionStart" and .command == "node" and .args == [$checker, "probe", $root, "--run-if-unset-or-true", "RUFF_FORMAT_ENABLED"])] | length' <<<"$ALL_HANDLERS")" HANDLERS="$(jq -c --arg checker '${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.mjs' --arg root '${CLAUDE_PLUGIN_ROOT}' '[.[] | select((.event == "SessionStart" and .command == "node" and .args == [$checker, "probe", $root, "--run-if-unset-or-true", "RUFF_FORMAT_ENABLED"]) | not)]' <<<"$ALL_HANDLERS")" if [[ "$PROBE_COUNT" == "1" ]]; then diff --git a/plugins/ruff-format/lib/prerequisites.ps1 b/plugins/ruff-format/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/ruff-format/lib/prerequisites.ps1 +++ b/plugins/ruff-format/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/ruff-format/lib/prerequisites.sh b/plugins/ruff-format/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/ruff-format/lib/prerequisites.sh +++ b/plugins/ruff-format/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/session-flow/.claude-plugin/plugin.json b/plugins/session-flow/.claude-plugin/plugin.json index 9f027bee3d..e976509a4f 100644 --- a/plugins/session-flow/.claude-plugin/plugin.json +++ b/plugins/session-flow/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "session-flow", - "version": "0.47.4", + "version": "0.48.0", "description": "Session lifecycle: workflow (next stage), handoff (save-point, resume prompt), continue-in-background, keep-going (resume after interruption or stall), find-handoff (recover a lost handoff), clean-stop (durable stopping point), retro and running-retro (retrospectives), audit-sessions, orient (where the session stands), orchestrate (delegation imperatives), reanchor (verify assumptions), reconcile (retire finished work), show-options (ranked skill menu), tidy-work (.work tiers), check, setup.", "author": { "name": "Melodic Software", diff --git a/plugins/session-flow/CHANGELOG.md b/plugins/session-flow/CHANGELOG.md index 3ebd954127..879321c396 100644 --- a/plugins/session-flow/CHANGELOG.md +++ b/plugins/session-flow/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog: session-flow plugin +## [0.48.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/session-flow:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + ## [0.47.4] - 2026-10-02 ### Changed diff --git a/plugins/session-flow/hooks/hooks.json b/plugins/session-flow/hooks/hooks.json index bd81d68503..8b7d31c7d4 100644 --- a/plugins/session-flow/hooks/hooks.json +++ b/plugins/session-flow/hooks/hooks.json @@ -15,6 +15,16 @@ "statusMessage": "Arming session observer..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /session-flow:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /session-flow:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/session-flow/lib/prerequisites.ps1 b/plugins/session-flow/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/session-flow/lib/prerequisites.ps1 +++ b/plugins/session-flow/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/session-flow/lib/prerequisites.sh b/plugins/session-flow/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/session-flow/lib/prerequisites.sh +++ b/plugins/session-flow/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/skill-quality/.claude-plugin/plugin.json b/plugins/skill-quality/.claude-plugin/plugin.json index f6eeaa9cce..3d5ada2196 100644 --- a/plugins/skill-quality/.claude-plugin/plugin.json +++ b/plugins/skill-quality/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "skill-quality", - "version": "0.27.0", + "version": "0.28.0", "description": "Skill-authoring QA tooling (skill-quality:check): a static contract checker running twenty-six deterministic checks over a Claude Code skill (frontmatter, invocation mode, description, listing cap, trigger keywords, line caps, broken refs, markdownlint, gotchas, evals), a shared skill-listing budget reporter, an evals.json schema with an eval-quality lint, and a measure-invocation harness that scores description auto-invocation probes on train and validation splits.", "author": { "name": "Melodic Software", diff --git a/plugins/skill-quality/CHANGELOG.md b/plugins/skill-quality/CHANGELOG.md index 519c2cd98a..61bba01eed 100644 --- a/plugins/skill-quality/CHANGELOG.md +++ b/plugins/skill-quality/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to the `skill-quality` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.28.0] - 2026-10-03 + +### Added + +- `/skill-quality:check-prerequisites`, a read-only report of whether the tools the plugin declares in `prerequisites.json` resolve, through the shared Node checker. `/skill-quality:check` keeps the skill-authoring gate. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker. + ## [0.27.0] - 2026-10-02 ### Added diff --git a/plugins/skill-quality/README.md b/plugins/skill-quality/README.md index 082fcea2a7..70ace2ea96 100644 --- a/plugins/skill-quality/README.md +++ b/plugins/skill-quality/README.md @@ -15,6 +15,7 @@ the reviewer to confirm the description still names that intent, or to restore t | Skill | What it does | |---|---| | `/skill-quality:check` | Runs the contract gate (`check`), reports the shared listing budget (`listing-budget`), schema-validates and quality-lints evals (`validate-evals`), or scores description auto-invocation probes (`measure-invocation`). | +| `/skill-quality:check-prerequisites` | Read-only report of whether the tools the plugin declares in `prerequisites.json` resolve. Installs nothing. | | `/skill-quality:setup` | Check-only: resolves and verifies the skills directory and prints the guidance for routing a non-default `skills_root` change through Claude Code. | ## Checks diff --git a/plugins/skill-quality/lib/prerequisites.ps1 b/plugins/skill-quality/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/skill-quality/lib/prerequisites.ps1 +++ b/plugins/skill-quality/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/skill-quality/lib/prerequisites.sh b/plugins/skill-quality/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/skill-quality/lib/prerequisites.sh +++ b/plugins/skill-quality/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/skill-quality/skills/check-prerequisites/SKILL.md b/plugins/skill-quality/skills/check-prerequisites/SKILL.md new file mode 100644 index 0000000000..ed668a008b --- /dev/null +++ b/plugins/skill-quality/skills/check-prerequisites/SKILL.md @@ -0,0 +1,38 @@ +--- +description: "Read-only report of whether the external tools the skill-quality plugin declares in prerequisites.json resolve, through the shared Node checker. /skill-quality:check lints a skill; this skill checks the plugin's own tools. Use when a skill-quality script stops on a missing tool, or before assuming a lint ran. Does not install." +user-invocable: true +disable-model-invocation: false +metadata: + workflow-stage: anytime + summary: Report whether the tools skill-quality declares resolve. Never installs. +--- + +## Purpose + +Run the read-only check. Do not install anything, and do not edit `prerequisites.json`. + +The plugin's `check` skill already means the skill-authoring gate, so the prerequisites check has its own name. + +## Check + +Run the shared checker through its stub, which reports a missing `node` instead of failing to start: + +```bash +sh "${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh" check "${CLAUDE_PLUGIN_ROOT}" --data-dir "${CLAUDE_PLUGIN_DATA}" +``` + +Where there is no `sh` (Windows without Git Bash), run the PowerShell stub with the same arguments instead: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1" check "${CLAUDE_PLUGIN_ROOT}" --data-dir "${CLAUDE_PLUGIN_DATA}" +``` + +Report the PASS/FAIL rows as a table. A plugin with no `prerequisites.json` prints that it declares no external dependency; report that and stop. On a FAIL, give the install hints the checker printed. Stop. + +## Next + +A passing check has no successor. For the skill-authoring gate, run `/skill-quality:check`. + +## Gotchas + +This skill does not install. A script's missing-tool message is not permission to install anything. diff --git a/plugins/skill-quality/skills/check-prerequisites/evals/evals.json b/plugins/skill-quality/skills/check-prerequisites/evals/evals.json new file mode 100644 index 0000000000..cd67b91e9d --- /dev/null +++ b/plugins/skill-quality/skills/check-prerequisites/evals/evals.json @@ -0,0 +1,29 @@ +{ + "skill_name": "check-prerequisites", + "evals": [ + { + "id": 1, + "name": "stopped-script-runs-the-checker", + "prompt": "A skill-quality script stopped because a tool was not on PATH. What is missing?", + "expected_output": "Runs /skill-quality:check-prerequisites, reports each PASS/FAIL row with the install hint the checker printed, and stops. Installs nothing.", + "files": [], + "expectations": [ + "Runs `lib/prerequisites.sh check` or the PowerShell stub with the same arguments", + "Reports every FAIL row with the install hint the checker printed", + "Installs nothing and does not edit prerequisites.json" + ] + }, + { + "id": 2, + "name": "passing-check-is-not-the-lint", + "prompt": "/skill-quality:check-prerequisites", + "expected_output": "When every row passes, reports the rows and stops. Skill lint stays /skill-quality:check.", + "files": [], + "expectations": [ + "Reports the rows the checker printed", + "On all PASS, does not run /skill-quality:check", + "Modifies no file" + ] + } + ] +} diff --git a/plugins/songwriting/.claude-plugin/plugin.json b/plugins/songwriting/.claude-plugin/plugin.json index 3a36a33ba9..5026b9100d 100644 --- a/plugins/songwriting/.claude-plugin/plugin.json +++ b/plugins/songwriting/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "songwriting", - "version": "1.6.1", + "version": "1.6.2", "description": "Songwriting craft companion: nine concern-scoped lyric-craft skills (workflow router, rhyme, object-writing, metaphor, meter-prosody, song-form, co-write, diagnose, practice) applying Pat Pattison's methods, with an object-writing agent that performs the sensory exercise itself and per-skill emission boundaries that route generation to the skill that owns it, plus Suno v5.5 prompt engineering (style prompts, tagged lyrics, genre templates, troubleshooting).", "author": { "name": "Melodic Software", diff --git a/plugins/songwriting/CHANGELOG.md b/plugins/songwriting/CHANGELOG.md index 0405b30bd3..fc9cea6ea5 100644 --- a/plugins/songwriting/CHANGELOG.md +++ b/plugins/songwriting/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `songwriting` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [1.6.2] - 2026-10-03 + +### Changed + +- Shared `prerequisites.sh`, `prerequisites.ps1` synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)); no change to this plugin's own behavior. + ## [1.6.1] - 2026-10-03 ### Changed diff --git a/plugins/songwriting/lib/prerequisites.ps1 b/plugins/songwriting/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/songwriting/lib/prerequisites.ps1 +++ b/plugins/songwriting/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/songwriting/lib/prerequisites.sh b/plugins/songwriting/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/songwriting/lib/prerequisites.sh +++ b/plugins/songwriting/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/source-control/.claude-plugin/plugin.json b/plugins/source-control/.claude-plugin/plugin.json index 597e61ddd3..ffce08d33c 100644 --- a/plugins/source-control/.claude-plugin/plugin.json +++ b/plugins/source-control/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "source-control", - "version": "0.78.2", + "version": "0.79.0", "description": "Git and GitHub delivery: /commit (convention-checked subject, Co-authored-by trailer, surgical staging), /pull-request (prep, create, CI monitoring, review triage, merge, CI logs), /babysit-prs (safe-by-default PR fleet loop, opt-in worker and autopilot tiers), /babysit-loop (merge lane; merge is human until the repo adopts it), /worktree, /resolve-conflicts (intent-first), /check, and /setup (layered source-control.md convention config; Conventional Commits by default).", "author": { "name": "Melodic Software", diff --git a/plugins/source-control/CHANGELOG.md b/plugins/source-control/CHANGELOG.md index f6e064ad82..e7426da742 100644 --- a/plugins/source-control/CHANGELOG.md +++ b/plugins/source-control/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `source-control` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.79.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/source-control:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. +- Hooks call `hook::require jq` where they called `hook::require_jq`. + ## [0.78.2] - 2026-10-02 ### Changed diff --git a/plugins/source-control/hooks/hook-utils.sh b/plugins/source-control/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/source-control/hooks/hook-utils.sh +++ b/plugins/source-control/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/source-control/hooks/hooks.json b/plugins/source-control/hooks/hooks.json index 8a58b7178c..dbc79cb0d9 100644 --- a/plugins/source-control/hooks/hooks.json +++ b/plugins/source-control/hooks/hooks.json @@ -1,6 +1,18 @@ { "description": "Gates pull-request and worktree operations: a PR body against the repo's PR-contract linkage gate on both the CLI and the GitHub MCP path, and a `git worktree add` target against the nesting invariant.", "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /source-control:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /source-control:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] + } + ], "PreToolUse": [ { "matcher": "Bash", diff --git a/plugins/source-control/hooks/pr-body-linkage-gate.sh b/plugins/source-control/hooks/pr-body-linkage-gate.sh index 51bdf9acfb..506feb1b65 100755 --- a/plugins/source-control/hooks/pr-body-linkage-gate.sh +++ b/plugins/source-control/hooks/pr-body-linkage-gate.sh @@ -129,7 +129,7 @@ start=${EPOCHREALTIME:-} hook::buffer_stdin_to INPUT || exit 0 -hook::require_jq "PreToolUse" "source-control-pr-body-linkage-gate" "$INPUT" +hook::require jq "PreToolUse" "source-control-pr-body-linkage-gate" "$INPUT" # Both payload fields in ONE jq process (#3509). The per-field form this # replaced was `printf '%s' "$INPUT" | jq -r … 2>/dev/null | tr -d '\r'` twice diff --git a/plugins/source-control/hooks/pr-linkage-mcp-gate.sh b/plugins/source-control/hooks/pr-linkage-mcp-gate.sh index d95e86d672..0762d43de1 100755 --- a/plugins/source-control/hooks/pr-linkage-mcp-gate.sh +++ b/plugins/source-control/hooks/pr-linkage-mcp-gate.sh @@ -87,7 +87,7 @@ start=${EPOCHREALTIME:-} hook::buffer_stdin_to INPUT || exit 0 [[ -n "$INPUT" ]] || exit 0 -hook::require_jq "PreToolUse" "source-control-pr-linkage-mcp-gate" "$INPUT" +hook::require jq "PreToolUse" "source-control-pr-linkage-mcp-gate" "$INPUT" # Every payload field this gate reads, in ONE jq process (#3509). The per-field # form this replaced ran `printf '%s' "$INPUT" | jq -r … 2>/dev/null` five times diff --git a/plugins/source-control/hooks/worktree-add-claim-gate.sh b/plugins/source-control/hooks/worktree-add-claim-gate.sh index 67bae563a4..56134de1a2 100755 --- a/plugins/source-control/hooks/worktree-add-claim-gate.sh +++ b/plugins/source-control/hooks/worktree-add-claim-gate.sh @@ -50,7 +50,7 @@ source "$HOOK_DIR/hook-utils.sh" source "$HOOK_DIR/worktree-path-lib.sh" hook::buffer_stdin_to INPUT || exit 0 -hook::require_jq "PostToolUse" "source-control-worktree-add-claim-gate" "$INPUT" +hook::require jq "PostToolUse" "source-control-worktree-add-claim-gate" "$INPUT" # ONE `jq` for the field and no `tr` behind it. The payload is fed through # `printf '%s' "$INPUT" | jq`, the form lib/hook-utils.sh prescribes for a hook diff --git a/plugins/source-control/hooks/worktree-add-containment-gate.sh b/plugins/source-control/hooks/worktree-add-containment-gate.sh index d6e7f0077d..cf125f8731 100755 --- a/plugins/source-control/hooks/worktree-add-containment-gate.sh +++ b/plugins/source-control/hooks/worktree-add-containment-gate.sh @@ -32,7 +32,7 @@ # command this hook cannot tokenize all ALLOW: a misplaced worktree is # reversible (`git worktree remove`), so blocking arbitrary Bash on a guess is # the worse failure. Same posture and same wrapper/segment machinery as the -# sibling pr-body-linkage-gate; jq gate is the fail-open hook::require_jq per +# sibling pr-body-linkage-gate; jq gate is the fail-open hook::require jq per # the posture doctrine in hook-utils.sh (this guard fails closed on no other # unparsable-input condition, so it does not join the fail-closed class). # @@ -79,7 +79,7 @@ source "$HOOK_DIR/worktree-path-lib.sh" source "$HOOK_DIR/../scripts/worktree-root-resolve.sh" hook::buffer_stdin_to INPUT || exit 0 -hook::require_jq "PreToolUse" "source-control-worktree-add-containment-gate" "$INPUT" +hook::require jq "PreToolUse" "source-control-worktree-add-containment-gate" "$INPUT" # ONE `jq` for the field and no `tr` behind it. The payload is fed through # `printf '%s' "$INPUT" | jq`, the form lib/hook-utils.sh prescribes for a hook diff --git a/plugins/source-control/lib/prerequisites.ps1 b/plugins/source-control/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/source-control/lib/prerequisites.ps1 +++ b/plugins/source-control/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/source-control/lib/prerequisites.sh b/plugins/source-control/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/source-control/lib/prerequisites.sh +++ b/plugins/source-control/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/speech/.claude-plugin/plugin.json b/plugins/speech/.claude-plugin/plugin.json index 73ee1e217d..81577c9b55 100644 --- a/plugins/speech/.claude-plugin/plugin.json +++ b/plugins/speech/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "speech", - "version": "0.1.3", + "version": "0.1.4", "description": "Text-to-speech narration. The narrate skill turns a script into narration.wav plus words.json, a start and end time for every word. The kokoro backend runs Kokoro-82M (Apache-2.0) locally through onnxruntime, with timings from the model's own durations. espeak-ng (GPL-3.0) is installed by you, never by the plugin. A SessionStart hook installs the locked Python packages, setup downloads the pinned model files, and check reports each missing prerequisite.", "author": { "name": "Melodic Software", diff --git a/plugins/speech/CHANGELOG.md b/plugins/speech/CHANGELOG.md index 3c7a2aaa9a..5a289efb80 100644 --- a/plugins/speech/CHANGELOG.md +++ b/plugins/speech/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to the `speech` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.1.4] - 2026-10-03 + +### Changed + +- **SessionStart reports a missing node.** One shell-form row runs the shared node-notice, and shared `hook-utils.sh`, `prerequisites.sh`, `prerequisites.ps1` are synced ([#5843](https://github.com/melodic-software/claude-code-plugins/issues/5843)). + ## [0.1.3] - 2026-10-03 ### Changed diff --git a/plugins/speech/hooks/hook-utils.sh b/plugins/speech/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/speech/hooks/hook-utils.sh +++ b/plugins/speech/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/speech/hooks/hooks.json b/plugins/speech/hooks/hooks.json index 22463f629f..b672a512c7 100644 --- a/plugins/speech/hooks/hooks.json +++ b/plugins/speech/hooks/hooks.json @@ -15,6 +15,16 @@ "statusMessage": "Installing speech Python packages..." } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /speech:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /speech:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/speech/lib/prerequisites.ps1 b/plugins/speech/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/speech/lib/prerequisites.ps1 +++ b/plugins/speech/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/speech/lib/prerequisites.sh b/plugins/speech/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/speech/lib/prerequisites.sh +++ b/plugins/speech/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/testing/.claude-plugin/plugin.json b/plugins/testing/.claude-plugin/plugin.json index 208962621f..c951f38eb5 100644 --- a/plugins/testing/.claude-plugin/plugin.json +++ b/plugins/testing/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "testing", - "version": "0.21.4", + "version": "0.22.0", "description": "Test-stage discipline: /testing:plan (coverage gaps, test plans), /testing:write (TDD authoring), /testing:run-e2e (live E2E and smoke checks), /testing:diagnose (failing-test root cause, with a fix-until-green workflow across files), /testing:audit (can't-fail test audit with a fail-closed gate), /testing:cleanup (rewrite, quarantine, or delete low-value tests behind a mutation gate), /testing:setup, and opt-in hooks that scan test files Claude writes and flag edits that weaken tests.", "author": { "name": "Melodic Software", diff --git a/plugins/testing/CHANGELOG.md b/plugins/testing/CHANGELOG.md index 2998ff25bf..35436d36a8 100644 --- a/plugins/testing/CHANGELOG.md +++ b/plugins/testing/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to the `testing` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.22.0] - 2026-10-03 + +### Added + +- A `SessionStart` hook row reports a missing `node` once per session, on both hook channels, and works on Windows without Git Bash. The notice names `/testing:check`. The row is shared across plugins, so a session with several of them sees one notice. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker and its `node-notice` stubs. + +### Changed + +- The shared hook helper has `hook::require ` in place of `hook::require_jq`. Its skip notice is built from the plugin's declared `prerequisites.json` entry and names `/:check`, not `/harness-ops:prerequisites`. +- Hooks call `hook::require jq` where they called `hook::require_jq`. + ## [0.21.4] - 2026-10-02 ### Changed diff --git a/plugins/testing/hooks/hook-utils.sh b/plugins/testing/hooks/hook-utils.sh index 4327545cef..24f5937c20 100644 --- a/plugins/testing/hooks/hook-utils.sh +++ b/plugins/testing/hooks/hook-utils.sh @@ -378,7 +378,7 @@ hook::raw_file_path() { # before #2146 every call site asserted a posture in a comment and nothing where # the posture is actually implemented explained it. # -# hook::require_jq fails OPEN — the default, and correct for most hooks +# hook::require jq fails OPEN — the default, and correct for most hooks # hook::require_jq_blocking fails CLOSED — for a guard that blocks an # irreversible operation # @@ -431,22 +431,66 @@ hook::raw_file_path() { # fail-closed path impossible to reach by accident, and make omission a visible # choice instead of an invisible default. -# Fail-OPEN jq gate — the default. For hooks whose input parsing cannot proceed -# without jq and whose finding is advisory. When jq is absent: a visible skip -# notice once per session and agent, renewed every eighth skip, then exit 0. Place after hook::check_enabled (and after any +# Fail-OPEN dependency gate — the default. For hooks whose work cannot proceed +# without the tool (jq, almost always) and whose finding is advisory. When +# is absent: a visible skip notice once per session and agent, renewed +# every eighth skip, then exit 0. Place after hook::check_enabled (and after any # jq-free applicability pre-filter), passing the buffered stdin for session # scoping. See the posture block above for when this is the WRONG choice. -# 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::require jq PostToolUse my-plugin "$INPUT" +# +# The notice is built from the plugin's declared prerequisites.json entry +# (docs/conventions/prerequisites/): its degrade text, first install doc link and +# check command. The lookup is jq-free, because the usual missing tool is jq. +# A plugin whose file lacks the entry gets generic degrade text and a +# /:check command derived from the plugin root. It never installs. +hook::require() { + command -v "$1" >/dev/null 2>&1 && return 0 + local id="$1" event="$2" plugin="$3" input="${4:-}" + if hook::notice_once "${plugin}-${id}" "$input"; then + local degrade docs check + hook::prerequisite_fields_to degrade docs check "$id" 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. If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." + "$plugin: $id not found on PATH — $degrade${docs:+ Install: $docs.} Run $check to verify. It does not install." fi exit 0 } +# hook::prerequisite_fields_to +# Reads the declared entry for from ${CLAUDE_PLUGIN_ROOT}/prerequisites.json +# with bash alone. An entry runs from its "id" to the next "id", so each entry +# carries "id" as its first key, as the convention's example does. An absent file or entry gives generic text and a +# check command derived from the plugin root. +hook::prerequisite_fields_to() { + local __hu_d="hook skipped for this session." __hu_i="" __hu_c="" + local __hu_root="${CLAUDE_PLUGIN_ROOT:-}" __hu_txt="" __hu_name="" + local __hu_s='[[:space:]]*:[[:space:]]*"(([^"\\]|\\.)*)"' + if [[ -r "$__hu_root/prerequisites.json" ]]; then + __hu_txt=$(<"$__hu_root/prerequisites.json") + if [[ "$__hu_txt" =~ \"id\"[[:space:]]*:[[:space:]]*\"$4\"(.*) ]]; then + __hu_txt="${BASH_REMATCH[1]}" + __hu_txt="${__hu_txt%%\"id\"[[:space:]]*:*}" + [[ "$__hu_txt" =~ \"degrade\"$__hu_s ]] && __hu_d="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"docs\"$__hu_s ]] && __hu_i="${BASH_REMATCH[1]}" + [[ "$__hu_txt" =~ \"check\"$__hu_s ]] && __hu_c="${BASH_REMATCH[1]}" + __hu_d="${__hu_d//\\\"/\"}" + fi + fi + if [[ -z "$__hu_c" ]]; then + __hu_name="${__hu_root%/}" + __hu_name="${__hu_name##*/}" + if [[ "$__hu_name" =~ ^[0-9] ]]; then + __hu_name="${__hu_root%/*}" + __hu_name="${__hu_name##*/}" + fi + __hu_c="/${__hu_name:-plugin}:check" + [[ -d "$__hu_root/skills/check-prerequisites" ]] && __hu_c+="-prerequisites" + fi + printf -v "$1" '%s' "$__hu_d" + printf -v "$2" '%s' "$__hu_i" + printf -v "$3" '%s' "$__hu_c" +} + # Fail-CLOSED jq gate (#2146) — for a guard that blocks an irreversible # operation, per the membership criterion in the posture block above. When jq is # absent the tool call is DENIED (exit 2) with jq named as the missing @@ -483,7 +527,9 @@ hook::require_jq_blocking() { else echo "Install jq (https://jqlang.org/download/) to restore the guard." >&2 fi - echo "If the harness-ops plugin is enabled, run /harness-ops:prerequisites to list every missing prerequisite." >&2 + local __hu_degrade __hu_docs __hu_check + hook::prerequisite_fields_to __hu_degrade __hu_docs __hu_check jq + echo "Run $__hu_check to verify." >&2 exit 2 } @@ -2736,7 +2782,7 @@ hook::data_json_to() { # empty, malformed, or cut short mid-document (hook::buffer_stdin_to rc 1, 2 # and 3 alike — an advisory PostToolUse hook allows all three, since the tool # already ran); no path in the payload, or one no matches; jq absent, -# after hook::require_jq's once per session and agent skip notice; a path +# after hook::require's once per session and agent skip notice; a path # hook::read_file_path rejects. # # No means "any payload carrying a path", for a hook whose matcher is @@ -2826,7 +2872,7 @@ hook::begin() { # jq is load-bearing for input parsing; absent → visible once per session and agent # skip notice instead of a silent no-op (dim-9 doctrine). - hook::require_jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" + hook::require jq "$__hu_bg_event" "$__hu_bg_plugin" "$INPUT" # The jq runs only for a payload whose raw text carries a notebook_path, # because without one the filter's own condition is false and it hands the diff --git a/plugins/testing/hooks/hooks.json b/plugins/testing/hooks/hooks.json index 3a9eaaf48e..c33ed04076 100644 --- a/plugins/testing/hooks/hooks.json +++ b/plugins/testing/hooks/hooks.json @@ -1674,6 +1674,16 @@ "timeout": 30 } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /testing:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /testing:check", + "timeout": 10, + "statusMessage": "Checking that node is on PATH..." + } + ] } ] } diff --git a/plugins/testing/hooks/test-scan-bash.sh b/plugins/testing/hooks/test-scan-bash.sh index bf6a5a9013..5e23035326 100755 --- a/plugins/testing/hooks/test-scan-bash.sh +++ b/plugins/testing/hooks/test-scan-bash.sh @@ -39,7 +39,7 @@ MAX_FILES=4 hook::buffer_stdin_to INPUT || exit 0 # Substring check first: jq stays the authority, stdout can carry the word too. [[ "$INPUT" == *'"bashEditDiff"'* ]] || exit 0 -hook::require_jq PostToolUse testing "$INPUT" +hook::require jq PostToolUse testing "$INPUT" # The globs are this plugin's own Write rows in hooks.json, generated from the # adapters' files: lists, so one jq call reads them along with the paths. diff --git a/plugins/testing/lib/prerequisites.ps1 b/plugins/testing/lib/prerequisites.ps1 index 008c281646..1c763effb5 100644 --- a/plugins/testing/lib/prerequisites.ps1 +++ b/plugins/testing/lib/prerequisites.ps1 @@ -5,7 +5,43 @@ # cannot run and node is itself a missing required dependency. # # pwsh -NoProfile -File prerequisites.ps1 check [--for ] +# +# node-notice is the one mode that never needs node, the counterpart of the same +# mode in prerequisites.sh (read its header for the contract): +# +# powershell -NoProfile -File prerequisites.ps1 node-notice [] $node = Get-Command -Name node -CommandType Application -ErrorAction SilentlyContinue | Select-Object -First 1 +if ($args.Count -gt 0 -and $args[0] -eq 'node-notice') { + if ($null -ne $node) { exit 0 } + $check = if ($args.Count -gt 1) { [string]$args[1] } else { '' } + $option = if ($args.Count -gt 2) { [string]$args[2] } else { '' } + if ($option -match '^[A-Z0-9_]+$') { + $enabled = [Environment]::GetEnvironmentVariable("CLAUDE_PLUGIN_OPTION_$option") + if ($enabled -and $enabled -ne 'true') { exit 0 } + } + $session = 'no-session' + if ([Console]::IsInputRedirected) { + $found = [regex]::Match([Console]::In.ReadToEnd(), '"session_id"\s*:\s*"([^"]*)"') + if ($found.Success -and $found.Groups[1].Value) { $session = $found.Groups[1].Value -replace '[^A-Za-z0-9_-]', '-' } + } + if ($session -ne 'no-session') { + $latch = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath 'claude-plugins-node-missing' + try { + [void][IO.Directory]::CreateDirectory($latch) + $stream = [IO.File]::Open((Join-Path -Path $latch -ChildPath $session), [IO.FileMode]::CreateNew) + $stream.Dispose() + } catch [IO.IOException] { + exit 0 + } catch { + } + } + $plugin = ($check.TrimStart('/') -split ':')[0] + if (-not $plugin) { $plugin = 'plugin' } + if (-not $check) { $check = 'the plugin check skill' } + $msg = "${plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run $check to verify. This notice shows once per session." + [Console]::Out.WriteLine('{"systemMessage":"' + $msg + '","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: ' + $msg + ' Tell the user."}}') + exit 0 +} if ($null -eq $node) { [Console]::Out.WriteLine('prerequisites: node was not found on PATH, so no prerequisite was checked. Install Node.js from https://nodejs.org/en/download, then run this check again.') exit 1 diff --git a/plugins/testing/lib/prerequisites.sh b/plugins/testing/lib/prerequisites.sh index 007f00d6c7..3e64508ae3 100755 --- a/plugins/testing/lib/prerequisites.sh +++ b/plugins/testing/lib/prerequisites.sh @@ -6,6 +6,48 @@ # cannot run and node is itself a missing required dependency. # # sh prerequisites.sh check [--for ] +# +# node-notice is the one mode that never needs node. A SessionStart hook runs it +# so a machine without node still hears about it: +# +# sh prerequisites.sh node-notice [] +# +# With node on PATH it exits 0 and prints nothing. Without node it prints one +# SessionStart notice on both hook channels, then exits 0, because Claude Code +# reads hook JSON only from a zero exit. Every plugin's notice shares one latch +# keyed by session id in the temp directory, so a session sees it once. Old +# latch files stay until the OS clears the temp directory. A plugin's kill +# switch (CLAUDE_PLUGIN_OPTION_) set to anything but true silences it. +# shellcheck shell=sh disable=SC2154 +if [ "${1:-}" = node-notice ]; then + command -v node >/dev/null 2>&1 && exit 0 + check="${2:-}" + case "${3:-}" in + "") ;; + *[!A-Z0-9_]*) ;; + *) + eval "enabled=\${CLAUDE_PLUGIN_OPTION_$3:-true}" + [ "$enabled" = true ] || exit 0 + ;; + esac + session=no-session + if [ ! -t 0 ]; then + input=$(cat) + id=$(printf '%s' "$input" | sed -n 's/.*"session_id" *: *"\([^"]*\)".*/\1/p' | tr -c 'A-Za-z0-9_\n-' '-') + [ -n "$id" ] && session=$id + fi + if [ "$session" != no-session ]; then + latch="${TMPDIR:-/tmp}/claude-plugins-node-missing" + mkdir -p "$latch" 2>/dev/null && { + mkdir "$latch/$session" 2>/dev/null || exit 0 + } + fi + plugin="${check#/}" + plugin="${plugin%%:*}" + msg="${plugin:-plugin}: node is not on PATH, so the hooks of this plugin and of every other plugin that launches through node cannot start and do nothing. Install Node.js from https://nodejs.org/en/download and restart Claude Code. Run ${check:-the plugin check skill} to verify. This notice shows once per session." + printf '{"systemMessage":"%s","hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"WARNING: %s Tell the user."}}\n' "$msg" "$msg" + exit 0 +fi if command -v node >/dev/null 2>&1; then case "$0" in */*) here="${0%/*}" ;; diff --git a/plugins/testing/scripts/gen-hook-filters.sh b/plugins/testing/scripts/gen-hook-filters.sh index 38b91bb38f..99024669db 100755 --- a/plugins/testing/scripts/gen-hook-filters.sh +++ b/plugins/testing/scripts/gen-hook-filters.sh @@ -51,6 +51,10 @@ json="$(jq -R . <<<"$globs" | jq -s '. as $globs | | cmd(["TEST_GUARDS_ENABLED"] + (if $extra.async then ["TEST_JUDGE_ENABLED"] else [] end); $script) + $if + $extra] }]; def judge($script; $extra): [{hooks: [cmd(["TEST_GUARDS_ENABLED", "TEST_JUDGE_ENABLED"]; $script) + $extra]}]; + # The node-notice row runs without node, so it is shell form; the prerequisites convention owns it. + def notice: [{hooks: [{type: "command", + command: "sh \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.sh\" node-notice /testing:check; ${BASH_VERSION:+exit}; powershell -NoProfile -ExecutionPolicy Bypass -File \"${CLAUDE_PLUGIN_ROOT}/lib/prerequisites.ps1\" node-notice /testing:check", + timeout: 10, statusMessage: "Checking that node is on PATH..."}]}]; { description: "Names what a Write or Edit to a test file removes and scans the written file, or a test file a Bash call changed, for tests that cannot fail (opt-in: test_guards_enabled); a task-end judge asks where each new test'"'"'s expected value came from (opt-in: test_judge_enabled, which needs test_guards_enabled).", hooks: { @@ -60,7 +64,7 @@ json="$(jq -R . <<<"$globs" | jq -s '. as $globs | + {timeout: 10, statusMessage: "Scanning test files the command changed..."}]}] + rows("test-judge-bg.sh"; {async: true})), Stop: judge("test-judge.sh"; {timeout: 240, statusMessage: "Collecting the test judge'"'"'s verdicts..."}), - SessionStart: judge("test-judge-start.sh"; {timeout: 30}) + SessionStart: (judge("test-judge-start.sh"; {timeout: 30}) + notice) } }')" || exit 2 diff --git a/plugins/testing/scripts/gen-hook-filters.test.sh b/plugins/testing/scripts/gen-hook-filters.test.sh index 48c5ceca8e..ed75688c81 100755 --- a/plugins/testing/scripts/gen-hook-filters.test.sh +++ b/plugins/testing/scripts/gen-hook-filters.test.sh @@ -37,10 +37,10 @@ check "test-judge-bg rows are async and match test-scan's if rows" \ check "Stop: one entry, test-judge.sh, timeout 240" \ '[[ "$(judge ".hooks.Stop | length")" == 1 && "$(judge ".hooks.Stop[0].hooks | length")" == 1 && "$(judge ".hooks.Stop[0].hooks[0].args[-1]")" == */test-judge.sh && "$(judge ".hooks.Stop[0].hooks[0].timeout")" == 240 ]]' -check "SessionStart: one entry, test-judge-start.sh" \ - '[[ "$(judge ".hooks.SessionStart | length")" == 1 && "$(judge ".hooks.SessionStart[0].hooks[0].args[-1]")" == */test-judge-start.sh ]]' +check "SessionStart: the judge entry first, test-judge-start.sh, then the node-notice entry" \ + '[[ "$(judge ".hooks.SessionStart | length")" == 2 && "$(judge ".hooks.SessionStart[1].hooks[0].command | contains(\"node-notice /testing:check\")")" == true && "$(judge ".hooks.SessionStart[0].hooks[0].args[-1]")" == */test-judge-start.sh ]]' check "every judge row is gated on test_guards_enabled and test_judge_enabled" \ - '[[ "$(judge "[(.hooks.PostToolUse[].hooks[] | select(.args[-1] | endswith(\"/test-judge-bg.sh\"))), .hooks.Stop[].hooks[], .hooks.SessionStart[].hooks[] + '[[ "$(judge "[(.hooks.PostToolUse[].hooks[] | select(.args[-1] | endswith(\"/test-judge-bg.sh\"))), .hooks.Stop[].hooks[], .hooks.SessionStart[0].hooks[] | .args[1:5] == [\"--require-true\", \"TEST_GUARDS_ENABLED\", \"--require-true\", \"TEST_JUDGE_ENABLED\"]] | unique | tostring")" == "[true]" ]]' check "the description names both options" \ '[[ "$(judge .description)" == *test_guards_enabled* && "$(judge .description)" == *test_judge_enabled* ]]' diff --git a/plugins/toolchain/.claude-plugin/plugin.json b/plugins/toolchain/.claude-plugin/plugin.json index c91d2f9c87..c9a235934d 100644 --- a/plugins/toolchain/.claude-plugin/plugin.json +++ b/plugins/toolchain/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "toolchain", - "version": "0.14.1", + "version": "0.15.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` with format-only `--fix` and gated `--code-fix`), 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", diff --git a/plugins/toolchain/CHANGELOG.md b/plugins/toolchain/CHANGELOG.md index 930eead166..7cf9c84834 100644 --- a/plugins/toolchain/CHANGELOG.md +++ b/plugins/toolchain/CHANGELOG.md @@ -3,6 +3,13 @@ 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.15.0] - 2026-10-03 + +### Added + +- `/toolchain:check-prerequisites`, a read-only report of whether the tools the plugin declares in `prerequisites.json` resolve, through the shared Node checker. `/toolchain:check` keeps the build and test run. +- `lib/prerequisites.mjs`, `lib/prerequisites.sh` and `lib/prerequisites.ps1`, the generated copies of the shared prerequisites checker. + ## [0.14.1] - 2026-10-02 ### Fixed diff --git a/plugins/toolchain/README.md b/plugins/toolchain/README.md index b3cdc1808e..6ad838bffd 100644 --- a/plugins/toolchain/README.md +++ b/plugins/toolchain/README.md @@ -3,11 +3,12 @@ A Claude Code plugin for **polyglot build/test/lint verification**. Detect the ecosystems a change touches and run the right build, test, and lint commands for each, with the consuming project's own documented commands overriding portable -defaults. Three skills, one concern: mechanical verification of changed code. +defaults. Four skills, one concern: mechanical verification of changed code. | Skill | Role | |---|---| | `/toolchain:check` | Build + test + lint for changed files, auto-detecting the affected ecosystems from git status. Also the reference skill other plugins compose for ecosystem detection and command resolution. | +| `/toolchain:check-prerequisites` | Read-only report of whether the tools the plugin declares in `prerequisites.json` resolve. Installs nothing. | | `/toolchain:lint` | Lint + format checks only. Faster than a build cycle; `--fix` is format-only, `--code-fix` runs semantic lint autofixes behind a confirmation / `--yes` gate. | | `/toolchain:setup` | Configure the plugin for a repo: `check` (read-only, default) reports the effective configuration; `apply` interviews and writes the tracked config. Re-runnable. | diff --git a/plugins/toolchain/lib/prerequisites.mjs b/plugins/toolchain/lib/prerequisites.mjs new file mode 100755 index 0000000000..57f65a79b5 --- /dev/null +++ b/plugins/toolchain/lib/prerequisites.mjs @@ -0,0 +1,597 @@ +#!/usr/bin/env node +// GENERATED from lib/prerequisites.mjs by scripts/sync-shared-copies.sh. Do not edit this copy: +// edit the canonical source, then rerun the script. +// Read a plugin's prerequisites.json and report which declared dependencies resolve. +// The contract is docs/conventions/prerequisites/README.md; the schema sits beside it. +// +// prerequisites.mjs report --plugin-root [--plugin-root ...] +// prerequisites.mjs check [--for ] [--data-dir ] +// prerequisites.mjs probe [--run-if-unset-or-true