diff --git a/plugins/core/.apm/skills/provider-adapter-author/README.md b/plugins/core/.apm/skills/provider-adapter-author/README.md index 9401197..1282b6c 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/README.md +++ b/plugins/core/.apm/skills/provider-adapter-author/README.md @@ -23,7 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, and the validator flag each needs | +| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, the validator flag each needs, and the rule that a standalone run and a run composed into by `agentsmd-author` behave identically | | `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on | | `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin | | `scripts/README.md` | Directory documentation for `scripts/` | diff --git a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md index 76111c7..97f7e16 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/.apm/skills/provider-adapter-author/SKILL.md @@ -18,6 +18,8 @@ metadata: - Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. +- Works standalone or composed-into by `agentsmd-author` — behave identically either way; do not assume a caller skill exists. Detect the provider file, confirm `AGENTS.md`, and run the closeout validator yourself in both cases (`references/provider-matrix.md`). + ## Step 1 — Detect Find the provider instruction file to convert. Before searching, read `references/provider-matrix.md` — skip it only when the target is already a known root `CLAUDE.md`, which is the common case. @@ -28,7 +30,7 @@ Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file: -- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it. +- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it. - **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. @@ -43,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] ``` -Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exit `2` is not a `FAIL`: it means the invocation or the input is wrong — a bad or missing argument, or a file that is not UTF-8 — so fix that, not the adapter. ## Step 4 — Report diff --git a/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md index 2d684cf..b747538 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md +++ b/plugins/core/.apm/skills/provider-adapter-author/references/provider-matrix.md @@ -21,3 +21,12 @@ silently drops every rule the adapter was supposed to defer to. Detection is a search, not a lookup: a repo may hold more than one of these, and each one converts independently against the same `AGENTS.md`. + +## Standalone and composed runs behave identically + +This skill is reached two ways: invoked directly by a user, and composed into by `agentsmd-author` +once it has written or updated the repo's `AGENTS.md`. Behave identically either way — do not +assume a caller skill exists. Detect the provider file yourself, confirm `AGENTS.md` yourself, and +run the closeout validator yourself, rather than treating any step as already done by the caller or +as something the caller will do afterwards. There is no handshake to rely on and no state passed +in beyond the file paths. diff --git a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh index 513ef1f..66ba72c 100755 --- a/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/.apm/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -31,6 +31,12 @@ Exit codes: 0 Adapter file passes all checks 1 One or more checks failed (empty file, no reference to AGENTS.md, excessive duplication, or file too long) + 2 Usage or input error — a bad or missing argument, a path that is not a + file, or a file that is not UTF-8. Nothing was graded, so there is no + FAIL line and no adapter edit to make: fix the invocation or the file's + encoding and re-run. Kept distinct from 1 because the skill's own + closeout tells the agent to fix every non-zero exit by editing the + provider file, which for a mistyped flag edits the wrong file forever. EOF } @@ -51,12 +57,12 @@ while [[ $# -gt 0 ]]; do --max-lines) if [[ $# -lt 2 ]]; then echo "Error: --max-lines requires a value (a non-negative integer)." >&2 - exit 1 + exit 2 fi MAX_LINES="$2" if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 - exit 1 + exit 2 fi shift 2 ;; @@ -71,7 +77,7 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then echo "Error: adapter-file and agents-md-file are required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 fi python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' @@ -85,15 +91,43 @@ max_lines = int(max_lines) if not os.path.isfile(adapter_path): print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(2) if not os.path.isfile(agents_md_path): print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(2) -with open(adapter_path, encoding="utf-8", errors="replace") as f: - adapter_content = f.read() -with open(agents_md_path, encoding="utf-8", errors="replace") as f: - agents_md_content = f.read() + +def read_text(path): + r"""File contents as text, UTF-8, BOM stripped. + + The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is + not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had + its first line — the `@AGENTS.md` import, which is the whole adapter — + silently treated as prose. The check then said "no reference to AGENTS.md" + told the author to add the line already sitting in front of them. Same + class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it + at the reader so no later check has to know about it. + + Decoding is strict, not errors="replace". Replacement mangles the file and + the checks then grade the mangling: a UTF-16 adapter whose first line is + `@AGENTS.md` decoded to interleaved NULs and failed as "no reference", + which is a true FAIL for a false reason and points the fix at the wrong + thing. A file this gate cannot read gets an encoding diagnostic and exit 2, + the same policy the ADR-0020 validators' read_text() uses. + """ + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except UnicodeDecodeError as exc: + print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte " + f"{exc.start}) — re-save it as UTF-8; this check does not guess " + "at other encodings.", file=sys.stderr) + sys.exit(2) + return text[1:] if text.startswith("\ufeff") else text + + +adapter_content = read_text(adapter_path) +agents_md_content = read_text(agents_md_path) has_fail = False @@ -124,8 +158,8 @@ if not has_reference: print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.") print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"") else: - print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.") - print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.") + print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.") + print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.") print() # --- Duplication check --- diff --git a/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats b/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats index e1277aa..9d18f1e 100644 --- a/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats +++ b/plugins/core/.apm/skills/provider-adapter-author/tests/validate-adapter.bats @@ -148,7 +148,7 @@ EOF ADAPTER="$TMPDIR/CLAUDE.md" echo "@AGENTS.md" > "$ADAPTER" run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" --max-lines - assert_failure + assert_failure 2 assert_output --partial "--max-lines requires a value" } @@ -156,7 +156,7 @@ EOF ADAPTER="$TMPDIR/CLAUDE.md" echo "@AGENTS.md" > "$ADAPTER" run bash "$SCRIPT" --max-lines abc "$ADAPTER" "$AGENTS_MD" - assert_failure + assert_failure 2 assert_output --partial "non-negative integer" } @@ -168,6 +168,47 @@ EOF @test "fails with a clear error when the adapter file argument is missing" { run bash "$SCRIPT" - assert_failure + assert_failure 2 assert_output --partial "required" } + +@test "a UTF-8 BOM before the @import line does not hide it" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'w', encoding='utf-8-sig').write('@AGENTS.md\n')" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_success +} + +@test "a usage error exits 2, a genuine finding exits 1" { + ADAPTER="$TMPDIR/CLAUDE.md" + echo "@AGENTS.md" > "$ADAPTER" + + run bash "$SCRIPT" --max-lines -3 "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + refute_output --partial "FAIL" + + : > "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "FAIL" +} + +@test "a non-UTF-8 adapter is reported as an encoding error, not as a missing reference" { + ADAPTER="$TMPDIR/CLAUDE.md" + python3 -c "import sys; open(sys.argv[1], 'wb').write('@AGENTS.md\n'.encode('utf-16'))" "$ADAPTER" + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 2 + assert_output --partial "not valid UTF-8" + refute_output --partial "no reference" +} + +@test "an @AGENTS.md folded into a sentence fails, and the message says the import needs its own line" { + ADAPTER="$TMPDIR/CLAUDE.md" + cat > "$ADAPTER" <<'EOF' +See @AGENTS.md for shared conventions. +EOF + run bash "$SCRIPT" "$ADAPTER" "$AGENTS_MD" + assert_failure 1 + assert_output --partial "no reference" + assert_output --partial "line of its own" +} diff --git a/plugins/core/skills/provider-adapter-author/README.md b/plugins/core/skills/provider-adapter-author/README.md index 9401197..1282b6c 100644 --- a/plugins/core/skills/provider-adapter-author/README.md +++ b/plugins/core/skills/provider-adapter-author/README.md @@ -23,7 +23,7 @@ Provide the path to the provider-specific file to convert (and the target repo r | File | Purpose | |------|---------| | `SKILL.md` | Skill instructions for agents | -| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, and the validator flag each needs | +| `references/provider-matrix.md` | Loaded at Step 1 before searching, unless the target is already a known root `CLAUDE.md`: known files per provider, which ones resolve a cross-file import, the validator flag each needs, and the rule that a standalone run and a run composed into by `agentsmd-author` behave identically | | `references/sources.md` | Provenance record — the in-repo ADR precedent this skill's design is modeled on | | `scripts/validate-adapter.sh` | Self-check gate: reference to AGENTS.md present, no excessive duplication, adapter stays thin | | `scripts/README.md` | Directory documentation for `scripts/` | diff --git a/plugins/core/skills/provider-adapter-author/SKILL.md b/plugins/core/skills/provider-adapter-author/SKILL.md index 76111c7..97f7e16 100644 --- a/plugins/core/skills/provider-adapter-author/SKILL.md +++ b/plugins/core/skills/provider-adapter-author/SKILL.md @@ -18,6 +18,8 @@ metadata: - Assume a provider has no cross-file import mechanism until you have confirmed it has one. Claude Code is the exception, not the rule: a `CLAUDE.md` may consist of nothing but `@path` lines, while the same `@AGENTS.md` line in a Cursor rule or a Copilot instructions file is inert text no tool resolves. Pass `--no-import-syntax` to `scripts/validate-adapter.sh` for those providers. +- Works standalone or composed-into by `agentsmd-author` — behave identically either way; do not assume a caller skill exists. Detect the provider file, confirm `AGENTS.md`, and run the closeout validator yourself in both cases (`references/provider-matrix.md`). + ## Step 1 — Detect Find the provider instruction file to convert. Before searching, read `references/provider-matrix.md` — skip it only when the target is already a known root `CLAUDE.md`, which is the common case. @@ -28,7 +30,7 @@ Then confirm `AGENTS.md` exists at the repo root. If it does not, stop and tell Read the provider file and `AGENTS.md` side by side. Separate the provider file's content into two buckets: lines that restate what `AGENTS.md` already owns (universal rules, conventions, project overview) versus lines that are genuinely provider-specific (tool syntax, IDE behavior, model-specific instructions). Rewrite the provider file: -- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import line, keep the provider-specific bucket below it. +- **Providers with import syntax** (Claude Code): replace the redundant bucket with an `@AGENTS.md` (or correct relative path) import on a line of its own, keep the provider-specific bucket below it. An import folded into a sentence is not the thin-adapter shape and `scripts/validate-adapter.sh` will not credit it. - **Providers without import syntax** (Cursor, Copilot, etc.): replace the redundant bucket with a short pointer sentence mentioning `AGENTS.md`, keep the provider-specific bucket. The provider file is the only file this skill ever writes. Never create or edit `AGENTS.md` — not in this step, not in any step, whatever the payoff looks like. @@ -43,7 +45,7 @@ Run the bundled check before finishing — this is the skill's own closeout gate bash scripts/validate-adapter.sh [--no-import-syntax] [--max-lines N] ``` -Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. +Fix any `FAIL` by editing the provider file, and re-run until it exits `0`. Exit `2` is not a `FAIL`: it means the invocation or the input is wrong — a bad or missing argument, or a file that is not UTF-8 — so fix that, not the adapter. ## Step 4 — Report diff --git a/plugins/core/skills/provider-adapter-author/references/provider-matrix.md b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md index 2d684cf..b747538 100644 --- a/plugins/core/skills/provider-adapter-author/references/provider-matrix.md +++ b/plugins/core/skills/provider-adapter-author/references/provider-matrix.md @@ -21,3 +21,12 @@ silently drops every rule the adapter was supposed to defer to. Detection is a search, not a lookup: a repo may hold more than one of these, and each one converts independently against the same `AGENTS.md`. + +## Standalone and composed runs behave identically + +This skill is reached two ways: invoked directly by a user, and composed into by `agentsmd-author` +once it has written or updated the repo's `AGENTS.md`. Behave identically either way — do not +assume a caller skill exists. Detect the provider file yourself, confirm `AGENTS.md` yourself, and +run the closeout validator yourself, rather than treating any step as already done by the caller or +as something the caller will do afterwards. There is no handshake to rely on and no state passed +in beyond the file paths. diff --git a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh index 513ef1f..66ba72c 100755 --- a/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh +++ b/plugins/core/skills/provider-adapter-author/scripts/validate-adapter.sh @@ -31,6 +31,12 @@ Exit codes: 0 Adapter file passes all checks 1 One or more checks failed (empty file, no reference to AGENTS.md, excessive duplication, or file too long) + 2 Usage or input error — a bad or missing argument, a path that is not a + file, or a file that is not UTF-8. Nothing was graded, so there is no + FAIL line and no adapter edit to make: fix the invocation or the file's + encoding and re-run. Kept distinct from 1 because the skill's own + closeout tells the agent to fix every non-zero exit by editing the + provider file, which for a mistyped flag edits the wrong file forever. EOF } @@ -51,12 +57,12 @@ while [[ $# -gt 0 ]]; do --max-lines) if [[ $# -lt 2 ]]; then echo "Error: --max-lines requires a value (a non-negative integer)." >&2 - exit 1 + exit 2 fi MAX_LINES="$2" if [[ ! "$MAX_LINES" =~ ^[0-9]+$ ]]; then echo "Error: --max-lines expects a non-negative integer, got '$MAX_LINES'." >&2 - exit 1 + exit 2 fi shift 2 ;; @@ -71,7 +77,7 @@ if [[ ${#ARGS[@]} -lt 2 ]]; then echo "Error: adapter-file and agents-md-file are required." >&2 echo "" >&2 usage >&2 - exit 1 + exit 2 fi python3 -u - "${ARGS[0]}" "${ARGS[1]}" "$NO_IMPORT_SYNTAX" "$MAX_LINES" <<'PYTHON' @@ -85,15 +91,43 @@ max_lines = int(max_lines) if not os.path.isfile(adapter_path): print(f"Error: '{adapter_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(2) if not os.path.isfile(agents_md_path): print(f"Error: '{agents_md_path}' is not a file.", file=sys.stderr) - sys.exit(1) + sys.exit(2) -with open(adapter_path, encoding="utf-8", errors="replace") as f: - adapter_content = f.read() -with open(agents_md_path, encoding="utf-8", errors="replace") as f: - agents_md_content = f.read() + +def read_text(path): + r"""File contents as text, UTF-8, BOM stripped. + + The BOM strip is not cosmetic. IMPORT_RE anchors on `^\s*@`, and a BOM is + not `\s` in Python, so a CLAUDE.md saved by an editor that emits one had + its first line — the `@AGENTS.md` import, which is the whole adapter — + silently treated as prose. The check then said "no reference to AGENTS.md" + told the author to add the line already sitting in front of them. Same + class of silent BOM miss recorded in scripts/skill-size-check.sh; strip it + at the reader so no later check has to know about it. + + Decoding is strict, not errors="replace". Replacement mangles the file and + the checks then grade the mangling: a UTF-16 adapter whose first line is + `@AGENTS.md` decoded to interleaved NULs and failed as "no reference", + which is a true FAIL for a false reason and points the fix at the wrong + thing. A file this gate cannot read gets an encoding diagnostic and exit 2, + the same policy the ADR-0020 validators' read_text() uses. + """ + try: + with open(path, encoding="utf-8") as fh: + text = fh.read() + except UnicodeDecodeError as exc: + print(f"Error: '{path}' is not valid UTF-8 ({exc.reason} at byte " + f"{exc.start}) — re-save it as UTF-8; this check does not guess " + "at other encodings.", file=sys.stderr) + sys.exit(2) + return text[1:] if text.startswith("\ufeff") else text + + +adapter_content = read_text(adapter_path) +agents_md_content = read_text(agents_md_path) has_fail = False @@ -124,8 +158,8 @@ if not has_reference: print(" Why: This provider resolves no cross-file import, so the adapter must point at AGENTS.md in prose; an `@AGENTS.md` line here is inert text.") print(" Fix: Add a sentence like \"See AGENTS.md at the repo root for shared conventions.\"") else: - print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line; merely naming the file in prose defers nothing.") - print(" Fix: Add an `@AGENTS.md` (or equivalent relative path) import line, or pass --no-import-syntax if this provider resolves no imports.") + print(" Why: A thin adapter must import AGENTS.md with an `@AGENTS.md` line of its own; naming the file mid-sentence or inside backticks is prose this check will not credit, and merely naming it defers nothing.") + print(" Fix: Put `@AGENTS.md` (or the equivalent relative path) alone on its own line, or pass --no-import-syntax if this provider resolves no imports.") print() # --- Duplication check ---