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 diff --git a/plugins/instruction-placement/context/findings-artifact.md b/plugins/instruction-placement/context/findings-artifact.md index 42ab30c6da..7a9a1d5b14 100644 --- a/plugins/instruction-placement/context/findings-artifact.md +++ b/plugins/instruction-placement/context/findings-artifact.md @@ -186,11 +186,15 @@ 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 -`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. +**`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. 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/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/scripts/detect.sh b/plugins/instruction-placement/scripts/detect.sh index e4dc783296..ce7ddfab26 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,46 @@ 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_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) ;; + *) 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 +451,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..5400531010 100755 --- a/plugins/instruction-placement/scripts/detect.test.sh +++ b/plugins/instruction-placement/scripts/detect.test.sh @@ -245,6 +245,97 @@ 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" "$?" +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 " + +# 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" diff --git a/plugins/instruction-placement/skills/audit/SKILL.md b/plugins/instruction-placement/skills/audit/SKILL.md index 639d1489ca..af374f55f7 100644 --- a/plugins/instruction-placement/skills/audit/SKILL.md +++ b/plugins/instruction-placement/skills/audit/SKILL.md @@ -149,6 +149,14 @@ and suppress every candidate whose `finding_id` it carries. That file is how a d 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 +``` + 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 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