From 694633e70858eb3953a4343cd52d97e3c0375499 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:15:33 -0400 Subject: [PATCH 1/6] feat(instruction-placement): add detect.sh identity subcommand Emits one IDENTITY record (check, claim, anchor/v1, finding_id, heading path) for a single section, built from the sweep's own per-file awk pass, so audit and delta stop deriving identity by hand. Refs #5166 Co-Authored-By: Claude Opus 5.5 --- .../instruction-placement/scripts/detect.sh | 125 +++++++++++++++++- .../scripts/detect.test.sh | 89 +++++++++++++ 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/plugins/instruction-placement/scripts/detect.sh b/plugins/instruction-placement/scripts/detect.sh index e4dc783296..51847b097a 100755 --- a/plugins/instruction-placement/scripts/detect.sh +++ b/plugins/instruction-placement/scripts/detect.sh @@ -27,16 +27,32 @@ # SKIP path reason # SUMMARY files_swept files_skipped sections rules # +# The `identity` subcommand emits one record for one section, using the same +# per-file pass as the sweep: +# +# IDENTITY path start check claim anchor/v1 finding_id heading_path +# +# check is instruction-placement/audit/; claim is narrower-scope: +# for demote and unloaded-convention: for promote. anchor/v1 is the first +# 8 hex of sha256 over the section's heading path (each enclosing heading, then +# its own) joined by 0x1F. finding_id is the first 16 hex of sha256 over +# check 0x1F claim 0x1F path 0x1F anchor. heading_path joins the same elements +# with ' > ' for display. The formula is a local copy: plugins do not import +# sibling-plugin files. +# # Frontmatter and fenced code blocks are excluded from signal and hint counting: # a convention stated inside an example block is illustrating, not instructing. # # Usage: # detect.sh [--root ] [--tier core|expanded] [--] [...] +# detect.sh identity [--root ] --file --start +# --lane demote|promote --destination # # no paths sweep the corpus (core tier, plus expanded unless --tier core) # ... emit facts for exactly these files # -# Exit: 0 on a successful emission (including zero findings); 2 on usage error. +# Exit: 0 on a successful emission (including zero findings); 2 on usage error +# (for `identity`, also when no section starts at --start). set -uo pipefail @@ -50,6 +66,8 @@ detect.sh — deterministic fact emitter for instruction-placement's audit. Usage: detect.sh [--root ] [--tier core|expanded] [--] [...] + detect.sh identity [--root ] --file --start + --lane demote|promote --destination --root repository root (default: cwd) --tier core instruction surfaces only; skip ordinary documentation @@ -64,6 +82,10 @@ Records (TSV, sorted, deterministic): RULE path scope globs scope: scoped | unscoped SKIP path reason SUMMARY files_swept files_skipped sections rules + IDENTITY path start check claim anchor/v1 finding_id heading_path + (identity subcommand only) + +identity rungs: path-scoped-rule, nested-agents-md, skill, linter, deletion. Facts only — this script classifies nothing and proposes nothing. @@ -79,8 +101,45 @@ die() { ROOT="$PWD" TIER="expanded" declare -a EXPLICIT=() +IDENTITY=0 +ID_FILE="" ID_START="" ID_LANE="" ID_RUNG="" + +id_die() { + printf 'ERROR: %s\n' "$1" >&2 + usage >&2 + exit 2 +} + +if [[ "${1:-}" == "identity" ]]; then + IDENTITY=1 + shift + while [[ $# -gt 0 ]]; do + [[ $# -lt 2 ]] && id_die "$1 needs a value" + case "$1" in + --root) ROOT="$2" ;; + --file) ID_FILE="$2" ;; + --start) ID_START="$2" ;; + --lane) ID_LANE="$2" ;; + --destination) ID_RUNG="$2" ;; + *) id_die "unknown argument: $1" ;; + esac + shift 2 + done + [[ -n "$ID_FILE" ]] || id_die "--file is required" + [[ "$ID_START" =~ ^[0-9]+$ ]] || id_die "--start must be an integer" + case "$ID_LANE" in + demote | promote) ;; + *) id_die "--lane must be demote or promote" ;; + esac + case "$ID_RUNG" in + path-scoped-rule | nested-agents-md | skill | linter | deletion) ;; + *) id_die "--destination must be path-scoped-rule, nested-agents-md, skill, linter, or deletion" ;; + esac + ID_FILE="${ID_FILE#./}" + EXPLICIT=("$ID_FILE") +fi -while [[ $# -gt 0 ]]; do +while [[ $IDENTITY -eq 0 && $# -gt 0 ]]; do case "$1" in -h | --help) usage @@ -391,6 +450,68 @@ lang_hints() { ' "$file" } +sha256_hex() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | cut -d' ' -f1 + else + shasum -a 256 | cut -d' ' -f1 + fi +} + +# One IDENTITY record for the section starting at --start, from the sweep's own +# SECTION records. The heading path takes, level by level, the nearest preceding +# section with a strictly lower level than the last one taken, so a skipped level +# (an H3 directly under an H1) leaves no gap and borrows no unrelated heading. +emit_identity() { + [[ -f "$ID_FILE" ]] || id_die "not a readable file: $ID_FILE" + local -a starts=() levels=() heads=() + local kind start level head + while IFS=$'\t' read -r kind _ start _ level head; do + [[ "$kind" == SECTION ]] || continue + starts+=("$start") + levels+=("$level") + heads+=("$head") + done < <(emit_file_facts "$ID_FILE") + + local want=$((10#$ID_START)) idx=-1 i + for ((i = 0; i < ${#starts[@]}; i++)); do + [[ "${starts[i]}" -eq "$want" ]] && idx=$i + done + ((idx >= 0)) || id_die "no section starts at line $want in $ID_FILE" + + local -a elems=("${heads[idx]}") + local cur="${levels[idx]}" + for ((i = idx - 1; i >= 0 && cur > 1; i--)); do + if ((levels[i] < cur)); then + elems=("${heads[i]}" "${elems[@]}") + cur="${levels[i]}" + fi + done + + local us=$'\x1f' joined="" display="" e claim + for e in "${elems[@]}"; do + joined+="${joined:+$us}$e" + display+="${display:+ > }$e" + done + local check="instruction-placement/audit/$ID_LANE" + if [[ "$ID_LANE" == demote ]]; then + claim="narrower-scope:$ID_RUNG" + else + claim="unloaded-convention:$ID_RUNG" + fi + local anchor fid + anchor="$(printf '%s' "$joined" | sha256_hex)" + anchor="${anchor:0:8}" + fid="$(printf '%s' "$check$us$claim$us$ID_FILE$us$anchor" | sha256_hex)" + printf 'IDENTITY\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$ID_FILE" "$want" "$check" "$claim" "$anchor" "${fid:0:16}" "$display" +} + +if ((IDENTITY)); then + emit_identity + exit 0 +fi + for f in "${FILES[@]}"; do tier="$(classify_tier "$f")" total="$(wc -l <"$f" 2>/dev/null | tr -d ' ')" diff --git a/plugins/instruction-placement/scripts/detect.test.sh b/plugins/instruction-placement/scripts/detect.test.sh index 5a8d823ae8..03a6455d37 100755 --- a/plugins/instruction-placement/scripts/detect.test.sh +++ b/plugins/instruction-placement/scripts/detect.test.sh @@ -245,6 +245,95 @@ assert_lacks_sub "no awk syntax error reaches the output" "$out" "syntax error" sections="$(printf '%s\n' "$out" | grep -c '^SECTION' || true)" assert_eq "a headed file yields a non-zero section count" "4" "$sections" +# ========================================================================== +# identity subcommand +# +# The two golden vectors below equal the output of claude-config's +# audit-pass finding-identity.sh `finding-id` for one site (surface=anchor), so +# a change here that drifts from that formula fails this suite. +# ========================================================================== +idr="$(mktemp -d)" +mkdir -p "$idr/docs" +cat >"$idr/CLAUDE.md" <<'EOF' +## C# naming + +Interfaces must be prefixed with I. +EOF +cat >"$idr/docs/deployment.md" <<'EOF' +## Deployment + +### Release checklist + +Tag the release. +EOF +cat >"$idr/skipped.md" <<'EOF' +# Top + +### Deep + +text + +## Mid + +### Under Mid + +#### Four +EOF +ident() { bash "$SCRIPT" identity --root "$idr" "$@" 2>&1; } + +out="$(ident --file CLAUDE.md --start 1 --lane demote --destination path-scoped-rule)" +assert_eq "identity: demote golden vector" \ + "$(printf 'IDENTITY\tCLAUDE.md\t1\tinstruction-placement/audit/demote\tnarrower-scope:path-scoped-rule\t4b322d9c\t6e9976d9d2e2c5a4\tC# naming')" "$out" + +out="$(ident --file docs/deployment.md --start 3 --lane promote --destination nested-agents-md)" +assert_eq "identity: nested promote golden vector" \ + "$(printf 'IDENTITY\tdocs/deployment.md\t3\tinstruction-placement/audit/promote\tunloaded-convention:nested-agents-md\tf2146d4b\t3f63ad6cc4466c0a\tDeployment > Release checklist')" "$out" + +a="$(ident --file docs/deployment.md --start 3 --lane promote --destination nested-agents-md)" +b="$(ident --file docs/deployment.md --start 3 --lane promote --destination nested-agents-md)" +assert_eq "identity: output is byte-identical across runs" "$a" "$b" + +cat >"$idr/CLAUDE.md" <<'EOF' +## C# naming + +Interfaces must be prefixed with I, and a paragraph +was rewritten and lengthened inside the section. + +Another paragraph appeared. +EOF +out="$(ident --file CLAUDE.md --start 1 --lane demote --destination path-scoped-rule)" +assert_eq "identity: editing a paragraph inside the section leaves the anchor and id unchanged" \ + "$(printf 'IDENTITY\tCLAUDE.md\t1\tinstruction-placement/audit/demote\tnarrower-scope:path-scoped-rule\t4b322d9c\t6e9976d9d2e2c5a4\tC# naming')" "$out" + +ident --file CLAUDE.md --start 1 --lane bogus --destination path-scoped-rule >/dev/null +assert_eq "identity: a bad lane is a usage error" "2" "$?" +ident --file CLAUDE.md --start 1 --lane demote --destination bogus >/dev/null +assert_eq "identity: a bad rung is a usage error" "2" "$?" +ident --file CLAUDE.md --start 2 --lane demote --destination skill >/dev/null +assert_eq "identity: a --start matching no section is a usage error" "2" "$?" +ident --file CLAUDE.md --start x --lane demote --destination skill >/dev/null +assert_eq "identity: a non-integer --start is a usage error" "2" "$?" +ident --file CLAUDE.md --lane demote --destination skill >/dev/null +assert_eq "identity: a missing --start is a usage error" "2" "$?" +ident --file CLAUDE.md --start 1 --lane demote >/dev/null +assert_eq "identity: a missing --destination is a usage error" "2" "$?" +out="$(ident --file CLAUDE.md --start 1 --lane bogus --destination skill)" +assert_has "identity: a usage error prints usage text" "$out" " detect.sh identity [--root ] --file --start " + +# Skipped levels: an H3 sits directly under the H1, and a later H4 skips the earlier +# H3 sibling (Deep) and takes the H2 above its own H3 parent. +out="$(ident --file skipped.md --start 3 --lane demote --destination skill)" +assert_eq "identity: an H3 directly under an H1 takes the H1 as its parent" \ + "Top > Deep" "$(printf '%s' "$out" | cut -f8)" +out="$(ident --file skipped.md --start 11 --lane demote --destination skill)" +assert_eq "identity: the nearest preceding record at each lower level is taken" \ + "Top > Mid > Under Mid > Four" "$(printf '%s' "$out" | cut -f8)" +expect="$(printf '%s\037%s' 'Top' 'Deep' | sha256sum | cut -c1-8)" +assert_eq "identity: the anchor hashes the path elements joined by 0x1F" \ + "$expect" "$(ident --file skipped.md --start 3 --lane demote --destination skill | cut -f6)" + +rm -rf "$idr" + # ========================================================================== rm -rf "$repo" "$sig" "$fence" "$hint" "$rules" "$corp" From 51dc154c9e5db53899b31cc407d1dca58b278d7b Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:23:47 -0400 Subject: [PATCH 2/6] docs(instruction-placement): derive anchor and finding_id through detect.sh identity Co-Authored-By: Claude Opus 5.5 --- .../instruction-placement/context/findings-artifact.md | 8 +++++++- .../instruction-placement/reference/consumer-config.md | 3 ++- plugins/instruction-placement/skills/audit/SKILL.md | 10 +++++++++- plugins/instruction-placement/skills/delta/SKILL.md | 4 +++- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/plugins/instruction-placement/context/findings-artifact.md b/plugins/instruction-placement/context/findings-artifact.md index 42ab30c6da..ccee733458 100644 --- a/plugins/instruction-placement/context/findings-artifact.md +++ b/plugins/instruction-placement/context/findings-artifact.md @@ -186,7 +186,13 @@ truncated to 8 hex**: for the section `### Release checklist` under `## Deployme `["Deployment", "Release checklist"]`. It is deliberately **not** a digest of the section's text and never a positional ordinal. -**The chain is reconstructed from the detector stream, and only `audit` and `delta` may do it.** A +**`audit` and `delta` derive `anchor/v1` and `finding_id` by running +`scripts/detect.sh identity --file --start --lane --destination `, and never +compute them by hand.** The script implements the formulas on this page and prints the heading path +it hashed. + +**The script reconstructs the chain from the detector stream, and only `audit` and `delta` may +derive one.** A `SECTION` record carries `path`, `start`, `end`, `level`, and its own `heading`; the ancestors are recoverable because the records for one file arrive in document order with their levels, so a section's enclosing path is the nearest preceding record at each lower level, walked up to level 1. diff --git a/plugins/instruction-placement/reference/consumer-config.md b/plugins/instruction-placement/reference/consumer-config.md index 4a0858754c..3817271e37 100644 --- a/plugins/instruction-placement/reference/consumer-config.md +++ b/plugins/instruction-placement/reference/consumer-config.md @@ -83,7 +83,8 @@ reported as malformed and does not suppress. anchor over the enclosing heading paths `## C# naming` and `## Deployment` → `### Release checklist`; their keys are the finding-suppression contract's `finding_id` over the same constituents. The derivation of both is owned by `context/findings-artifact.md` under "Finding ids and their -constituents". Anyone editing an example re-derives the anchor and then the key, in that order: +constituents". Anyone editing an example re-derives the anchor and then the key with `scripts/detect.sh identity`, +never by hand, in that order: editing an anchor changes the key that hashes it, and an entry whose constituents no longer hash to its own key is reported as malformed and suppresses nothing. A hand-written example would be an example nobody can copy. diff --git a/plugins/instruction-placement/skills/audit/SKILL.md b/plugins/instruction-placement/skills/audit/SKILL.md index 639d1489ca..6534cfb6d8 100644 --- a/plugins/instruction-placement/skills/audit/SKILL.md +++ b/plugins/instruction-placement/skills/audit/SKILL.md @@ -145,7 +145,15 @@ operator's `declined` decision must survive a re-audit. Before ranking, resolve the tracked suppression surface `.claude/instruction-placement.md` across its three layers ([`${CLAUDE_PLUGIN_ROOT}/reference/consumer-config.md`](${CLAUDE_PLUGIN_ROOT}/reference/consumer-config.md)) -and suppress every candidate whose `finding_id` it carries. That file is how a decline reaches a +and suppress every candidate whose `finding_id` it carries. Derive a candidate's `anchor/v1` and +`finding_id` by running `detect.sh identity`, never by hand: + +```bash +"${CLAUDE_PLUGIN_ROOT}/scripts/detect.sh" identity --file --start \ + --lane demote|promote --destination +``` + +That file is how a decline reaches a checkout the findings artifact never does, so a sweep that ignores it re-proposes decisions the operator already made somewhere else. diff --git a/plugins/instruction-placement/skills/delta/SKILL.md b/plugins/instruction-placement/skills/delta/SKILL.md index 5ba329735a..0e83ae1bd8 100644 --- a/plugins/instruction-placement/skills/delta/SKILL.md +++ b/plugins/instruction-placement/skills/delta/SKILL.md @@ -140,7 +140,9 @@ Each step names what "done" looks like, so a partial run is visible rather than verdict and this run's. 5. Check index sync and reachability. *Done when:* both verdicts are recorded, since they are independent questions. -6. Derive each surviving finding's `finding_id` and suppress every one the merged surface carries; +6. Derive each surviving finding's `anchor/v1` and `finding_id` with `detect.sh identity`: + `"${CLAUDE_PLUGIN_ROOT}/scripts/detect.sh" identity --file --start --lane --destination `, + never by hand, and suppress every one the merged surface carries; also suppress what this branch's artifact records as `declined` or `applied`. *Done when:* no suppressed id appears in the report under any shape, and every entry that did **not** suppress (personal-only, malformed, or not evaluated this run) is listed with its From 092fb3f7add046bce6057834c75dd4061015bfcd Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:29:03 -0400 Subject: [PATCH 3/6] chore(instruction-placement): bump to 0.16.0 for detect.sh identity Co-Authored-By: Claude Opus 5.5 --- plugins/instruction-placement/.claude-plugin/plugin.json | 2 +- plugins/instruction-placement/CHANGELOG.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/plugins/instruction-placement/.claude-plugin/plugin.json b/plugins/instruction-placement/.claude-plugin/plugin.json index b4dcf937c0..3d9cf43ce8 100644 --- a/plugins/instruction-placement/.claude-plugin/plugin.json +++ b/plugins/instruction-placement/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "instruction-placement", - "version": "0.15.22", + "version": "0.16.0", "description": "Routes agent-instruction content to the surface that loads it at the right moment. The audit skill sweeps a repository's instruction layer and its ordinary markdown for content whose scope is narrower than the surface carrying it, meaning conventions keyed to one file type or one subtree sitting in an always-loaded CLAUDE.md or AGENTS.md, and for normative conventions stranded in documentation Claude never loads at all, then classifies each against a routing rubric and proposes a destination whose `paths:` glob is machine-validated before it is ever offered. Safety-class content (irreversible actions, secrets, data integrity, external publication, compliance, agent authority) is hard-denied from demotion and reported as held back rather than proposed, because demotion trades guaranteed presence for conditional presence: a deferred surface is absent until a read matches it, absent after a compaction until that trigger recurs, and never inherited by a subagent, which re-acquires it only by reading a covered path itself. Every accepted move regenerates an always-loaded index of deferred surfaces, which is what keeps a demoted rule discoverable from any context that has not happened to touch a path it covers. The audit is read-only and emits a diffable findings artifact; realignment is a separate skill gated per item with no blanket-approve path; a deterministic check skill gates that every rule glob still resolves and the index is current; a migrate skill moves a repository to AGENTS.md as the content home and keeps a CLAUDE.md shim while one is needed; and a setup skill verifies the one thing no other gate can see: that nothing in the repository stops Claude Code reading the index target, since a CLAUDE.md in the working directory or above it is read instead of the AGENTS.md beside it.", "author": { "name": "Melodic Software", diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index b29c2b7b56..bd57b564e7 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -3,6 +3,13 @@ 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.16.0] - 2026-09-29 + +### Added + +- **`detect.sh identity` derives a finding's anchor and `finding_id`.** `audit` and `delta` call it instead of computing `anchor/v1` and `finding_id` by hand, so both skills produce the same identity for the same finding + ([#5166](https://github.com/melodic-software/claude-code-plugins/issues/5166)). + ## [0.15.22] - 2026-09-29 ### Fixed From 206d0ddb9b6de299e01b308ec600190f4044d1fe Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:36:40 -0400 Subject: [PATCH 4/6] fix(instruction-placement): reject an absolute identity --file, tighten docs An absolute --file hashed to a different finding_id than the repo-relative path for the same section. detect.sh identity now exits 2 on an absolute --file, with a test. Also restores the "That file" antecedent in the audit skill, rewraps the findings-artifact paragraph, and corrects the golden-vector comment. Co-Authored-By: Claude Opus 5.5 --- .../context/findings-artifact.md | 10 ++++------ plugins/instruction-placement/scripts/detect.sh | 1 + plugins/instruction-placement/scripts/detect.test.sh | 4 +++- plugins/instruction-placement/skills/audit/SKILL.md | 12 ++++++------ 4 files changed, 14 insertions(+), 13 deletions(-) diff --git a/plugins/instruction-placement/context/findings-artifact.md b/plugins/instruction-placement/context/findings-artifact.md index ccee733458..7a9a1d5b14 100644 --- a/plugins/instruction-placement/context/findings-artifact.md +++ b/plugins/instruction-placement/context/findings-artifact.md @@ -191,12 +191,10 @@ never a positional ordinal. compute them by hand.** The script implements the formulas on this page and prints the heading path it hashed. -**The script reconstructs the chain from the detector stream, and only `audit` and `delta` may -derive one.** A -`SECTION` record carries `path`, `start`, `end`, `level`, and its own `heading`; the ancestors are -recoverable because the records for one file arrive in document order with their levels, so a -section's enclosing path is the nearest preceding record at each lower level, walked up to level 1. -Both skills that run the detector derive it that way and never by re-reading the file. +The script reconstructs the chain from the detector stream. A `SECTION` record carries `path`, +`start`, `end`, `level`, and its own `heading`; the ancestors are recoverable because the records for +one file arrive in document order with their levels, so a section's enclosing path is the nearest +preceding record at each lower level, walked up to level 1. Both skills that run the detector derive it that way and never by re-reading the file. **`realign` never derives one.** It has no detector stream, so it reads `anchor/v1` and `finding_id` from the Finding record `audit` wrote and carries them into the suppression entry verbatim. That is diff --git a/plugins/instruction-placement/scripts/detect.sh b/plugins/instruction-placement/scripts/detect.sh index 51847b097a..ce7ddfab26 100755 --- a/plugins/instruction-placement/scripts/detect.sh +++ b/plugins/instruction-placement/scripts/detect.sh @@ -126,6 +126,7 @@ if [[ "${1:-}" == "identity" ]]; then shift 2 done [[ -n "$ID_FILE" ]] || id_die "--file is required" + [[ "$ID_FILE" != /* ]] || id_die "--file must be a path relative to --root: $ID_FILE" [[ "$ID_START" =~ ^[0-9]+$ ]] || id_die "--start must be an integer" case "$ID_LANE" in demote | promote) ;; diff --git a/plugins/instruction-placement/scripts/detect.test.sh b/plugins/instruction-placement/scripts/detect.test.sh index 03a6455d37..5400531010 100755 --- a/plugins/instruction-placement/scripts/detect.test.sh +++ b/plugins/instruction-placement/scripts/detect.test.sh @@ -249,7 +249,7 @@ assert_eq "a headed file yields a non-zero section count" "4" "$sections" # identity subcommand # # The two golden vectors below equal the output of claude-config's -# audit-pass finding-identity.sh `finding-id` for one site (surface=anchor), so +# audit-pass finding-identity.sh `finding-id` for one site (surface=, anchor=), so # a change here that drifts from that formula fails this suite. # ========================================================================== idr="$(mktemp -d)" @@ -317,6 +317,8 @@ ident --file CLAUDE.md --lane demote --destination skill >/dev/null assert_eq "identity: a missing --start is a usage error" "2" "$?" ident --file CLAUDE.md --start 1 --lane demote >/dev/null assert_eq "identity: a missing --destination is a usage error" "2" "$?" +ident --file "$idr/CLAUDE.md" --start 1 --lane demote --destination skill >/dev/null +assert_eq "identity: an absolute --file is a usage error" "2" "$?" out="$(ident --file CLAUDE.md --start 1 --lane bogus --destination skill)" assert_has "identity: a usage error prints usage text" "$out" " detect.sh identity [--root ] --file --start " diff --git a/plugins/instruction-placement/skills/audit/SKILL.md b/plugins/instruction-placement/skills/audit/SKILL.md index 6534cfb6d8..af374f55f7 100644 --- a/plugins/instruction-placement/skills/audit/SKILL.md +++ b/plugins/instruction-placement/skills/audit/SKILL.md @@ -145,18 +145,18 @@ operator's `declined` decision must survive a re-audit. Before ranking, resolve the tracked suppression surface `.claude/instruction-placement.md` across its three layers ([`${CLAUDE_PLUGIN_ROOT}/reference/consumer-config.md`](${CLAUDE_PLUGIN_ROOT}/reference/consumer-config.md)) -and suppress every candidate whose `finding_id` it carries. Derive a candidate's `anchor/v1` and -`finding_id` by running `detect.sh identity`, never by hand: +and suppress every candidate whose `finding_id` it carries. That file is how a decline reaches a +checkout the findings artifact never does, so a sweep that ignores it re-proposes decisions the +operator already made somewhere else. + +Derive a candidate's `anchor/v1` and `finding_id` by running `detect.sh identity`, never by hand. +`--file` is the repo-relative path: ```bash "${CLAUDE_PLUGIN_ROOT}/scripts/detect.sh" identity --file --start \ --lane demote|promote --destination ``` -That file is how a decline reaches a -checkout the findings artifact never does, so a sweep that ignores it re-proposes decisions the -operator already made somewhere else. - Three obligations, none optional. **Read, never write**: `realign` composes an entry behind its per-item gate and nothing here does. **Report the suppressions**, each with its reason, date, and contributing layer, and every entry that did *not* suppress: personal-only, malformed, or outside From 9d0f0624c1c19a24c3bd94fc48ded381d6acb553 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:27:09 -0400 Subject: [PATCH 5/6] fix(instruction-placement): identity rejects noncanonical paths and missing sha256 tools Refs #5166 Co-Authored-By: Claude Opus 5.5 --- plugins/instruction-placement/CHANGELOG.md | 1 + plugins/instruction-placement/scripts/detect.sh | 6 +++++- plugins/instruction-placement/scripts/detect.test.sh | 6 ++++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/instruction-placement/CHANGELOG.md b/plugins/instruction-placement/CHANGELOG.md index bd57b564e7..4545a793b1 100644 --- a/plugins/instruction-placement/CHANGELOG.md +++ b/plugins/instruction-placement/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the `instruction-placement` plugin are documented here. F - **`detect.sh identity` derives a finding's anchor and `finding_id`.** `audit` and `delta` call it instead of computing `anchor/v1` and `finding_id` by hand, so both skills produce the same identity for the same finding ([#5166](https://github.com/melodic-software/claude-code-plugins/issues/5166)). + It rejects a `--file` that is not a canonical relative path and fails when neither `sha256sum` nor `shasum` exists, so no alias or unhashed value produces an id. ## [0.15.22] - 2026-09-29 diff --git a/plugins/instruction-placement/scripts/detect.sh b/plugins/instruction-placement/scripts/detect.sh index ce7ddfab26..d41503669f 100755 --- a/plugins/instruction-placement/scripts/detect.sh +++ b/plugins/instruction-placement/scripts/detect.sh @@ -136,7 +136,11 @@ if [[ "${1:-}" == "identity" ]]; then path-scoped-rule | nested-agents-md | skill | linter | deletion) ;; *) id_die "--destination must be path-scoped-rule, nested-agents-md, skill, linter, or deletion" ;; esac - ID_FILE="${ID_FILE#./}" + case "/$ID_FILE/" in + */./* | */../* | *//* | *\\*) id_die "--file must be a canonical relative path (no ., .., empty or backslash segments): $ID_FILE" ;; + esac + command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || + id_die "identity needs sha256sum or shasum" EXPLICIT=("$ID_FILE") fi diff --git a/plugins/instruction-placement/scripts/detect.test.sh b/plugins/instruction-placement/scripts/detect.test.sh index 5400531010..b00bdb74c4 100755 --- a/plugins/instruction-placement/scripts/detect.test.sh +++ b/plugins/instruction-placement/scripts/detect.test.sh @@ -319,6 +319,12 @@ ident --file CLAUDE.md --start 1 --lane demote >/dev/null assert_eq "identity: a missing --destination is a usage error" "2" "$?" ident --file "$idr/CLAUDE.md" --start 1 --lane demote --destination skill >/dev/null assert_eq "identity: an absolute --file is a usage error" "2" "$?" +for bad in ./CLAUDE.md ././CLAUDE.md docs/../CLAUDE.md docs//x.md 'docs\x.md'; do + ident --file "$bad" --start 1 --lane demote --destination skill >/dev/null + assert_eq "identity: noncanonical --file '$bad' is a usage error" "2" "$?" +done +PATH=/nonexistent "$BASH" "$SCRIPT" identity --root "$idr" --file CLAUDE.md --start 1 --lane demote --destination skill >/dev/null 2>&1 +assert_eq "identity: no sha256 utility is a usage error, not a bogus id" "2" "$?" out="$(ident --file CLAUDE.md --start 1 --lane bogus --destination skill)" assert_has "identity: a usage error prints usage text" "$out" " detect.sh identity [--root ] --file --start " From 11834f7555982c7f2f94f75f4790aea34ac1239f Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:43:40 -0400 Subject: [PATCH 6/6] fix(instruction-placement): satisfy shellcheck SC2249 in identity path check Refs #5166 Co-Authored-By: Claude Opus 5.5 --- plugins/instruction-placement/scripts/detect.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/instruction-placement/scripts/detect.sh b/plugins/instruction-placement/scripts/detect.sh index d41503669f..806264d36a 100755 --- a/plugins/instruction-placement/scripts/detect.sh +++ b/plugins/instruction-placement/scripts/detect.sh @@ -138,6 +138,7 @@ if [[ "${1:-}" == "identity" ]]; then esac case "/$ID_FILE/" in */./* | */../* | *//* | *\\*) id_die "--file must be a canonical relative path (no ., .., empty or backslash segments): $ID_FILE" ;; + *) ;; esac command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || id_die "identity needs sha256sum or shasum"