Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion plugins/instruction-placement/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions plugins/instruction-placement/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 9 additions & 5 deletions plugins/instruction-placement/context/findings-artifact.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> --start <n> --lane <lane> --destination <rung>`, 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
Expand Down
3 changes: 2 additions & 1 deletion plugins/instruction-placement/reference/consumer-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
126 changes: 124 additions & 2 deletions plugins/instruction-placement/scripts/detect.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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/<lane>; claim is narrower-scope:<rung>
# for demote and unloaded-convention:<rung> 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 <dir>] [--tier core|expanded] [--] [<path>...]
# detect.sh identity [--root <dir>] --file <path> --start <n>
# --lane demote|promote --destination <rung>
#
# no paths sweep the corpus (core tier, plus expanded unless --tier core)
# <path>... 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

Expand All @@ -50,6 +66,8 @@ detect.sh — deterministic fact emitter for instruction-placement's audit.

Usage:
detect.sh [--root <dir>] [--tier core|expanded] [--] [<path>...]
detect.sh identity [--root <dir>] --file <path> --start <n>
--lane demote|promote --destination <rung>

--root <dir> repository root (default: cwd)
--tier core instruction surfaces only; skip ordinary documentation
Expand All @@ -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.

Expand All @@ -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")
Comment on lines +128 to +140

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject noncanonical paths before hashing

The absolute-path check only rejects POSIX /... spellings and the subsequent normalization removes only one leading ./, so aliases such as ././CLAUDE.md, docs/../CLAUDE.md, and Git Bash drive paths like C:/repo/CLAUDE.md can identify the same file with different finding_id values; ../... can also escape --root. Because the path bytes are part of the hash, these accepted spellings defeat deterministic suppression matching, so require a canonical path contained beneath the root before hashing it.

Useful? React with 👍 / 👎.

fi

while [[ $# -gt 0 ]]; do
while [[ $IDENTITY -eq 0 && $# -gt 0 ]]; do
case "$1" in
-h | --help)
usage
Expand Down Expand Up @@ -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
Comment on lines +454 to +459

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail when no SHA-256 utility is available

On a host with neither sha256sum nor shasum, this fallback fails inside command substitutions, but the script lacks set -e and still exits 0 after emitting an IDENTITY record with empty anchor and finding_id fields. In that environment, audit and delta silently fail to match existing suppressions and may write malformed finding records; explicitly check for either utility and return a nonzero error before emitting.

Useful? React with 👍 / 👎.

}

# 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 ' ')"
Expand Down
91 changes: 91 additions & 0 deletions plugins/instruction-placement/scripts/detect.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=<repo-relative path>, anchor=<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 <dir>] --file <path> --start <n>"

# 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"

Expand Down
8 changes: 8 additions & 0 deletions plugins/instruction-placement/skills/audit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> --start <n> \
--lane demote|promote --destination <rung>
```

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
Expand Down
4 changes: 3 additions & 1 deletion plugins/instruction-placement/skills/delta/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path> --start <n> --lane <lane> --destination <rung>`,
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
Expand Down
Loading