diff --git a/.github/workflows/lint-agents.yml b/.github/workflows/lint-agents.yml index eb995925..2b91136d 100644 --- a/.github/workflows/lint-agents.yml +++ b/.github/workflows/lint-agents.yml @@ -21,6 +21,12 @@ on: - "support/**" - "spatial-computing/**" - "specialized/**" + # Changing the linter itself has to run it. Without these the whole-roster + # job below never fires, and a new rule ships untested: the color check + # went in this way and nothing in CI had ever looked at a color value. + - "scripts/lint-agents.sh" + - "scripts/convert.sh" + - "scripts/lib.sh" jobs: lint: @@ -65,3 +71,36 @@ jobs: run: | chmod +x scripts/check-agent-originality.sh ./scripts/check-agent-originality.sh $CHANGED_FILES + + lint-all: + name: Whole roster, when the linter changes + runs-on: ubuntu-latest + # The job above lints the agents a PR touched, which is the right scope for + # an agent PR and the wrong one for a rule change: a new rule has to run + # against every agent before it lands, or it either breaks a division nobody + # edited or quietly does nothing. + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Did the linter or the color map change? + id: rules + run: | + RULES="scripts/lint-agents.sh scripts/convert.sh scripts/lib.sh" + CHANGED=$(git diff --name-only --diff-filter=ACMR \ + "origin/${{ github.base_ref }}...HEAD" -- $RULES) + if [ -n "$CHANGED" ]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "Linting the whole roster because these changed:" + echo "$CHANGED" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "Linter unchanged - the changed-files job is the right scope here." + fi + + - name: Lint every agent in the roster + if: steps.rules.outputs.changed == 'true' + run: | + chmod +x scripts/lint-agents.sh + ./scripts/lint-agents.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3059e3b1..8ddf1520 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ Every agent should follow this structure: --- name: Agent Name description: One-line description of the agent's specialty and focus -color: colorname or "#hexcode" +color: colorname or "#hexcode" # see the note below — not any name works emoji: 🎯 vibe: One-line personality hook — what makes this agent memorable services: # optional — only if the agent requires external services @@ -154,6 +154,13 @@ Measurable outcomes: Advanced techniques and approaches the agent masters ``` +**About `color`.** A `#RRGGBB` value always works. A color *name* only works if +`resolve_opencode_color()` in `scripts/convert.sh` knows it — anything else is +silently rewritten to grey in the OpenCode integration, which reads as a choice +rather than a mistake. `scripts/lint-agents.sh` reads that list straight out of +the converter, rejects a name that is not in it, and prints the names that are. +To use a new name, add it to the map in the same PR. + ### Agent Structure Agent files are organized into two semantic groups that map to diff --git a/scripts/convert.sh b/scripts/convert.sh index 2be6de20..226dbc58 100755 --- a/scripts/convert.sh +++ b/scripts/convert.sh @@ -214,6 +214,10 @@ HEREDOC } # Map known color names and normalize to OpenCode-safe #RRGGBB values. +# An unknown name lands on grey, which looks like a choice rather than a miss, +# so scripts/lint-agents.sh reads this list and rejects a color that is not in +# it. Values follow the CSS named color where one exists (teal, navy) and +# Tailwind's 500 shade otherwise (gray, slate). resolve_opencode_color() { local c="$1" local mapped @@ -241,6 +245,8 @@ resolve_opencode_color() { lime) mapped="#84CC16" ;; gray) mapped="#6B7280" ;; fuchsia) mapped="#D946EF" ;; + slate) mapped="#64748B" ;; + navy) mapped="#000080" ;; *) mapped="$c" ;; esac diff --git a/scripts/lint-agents.sh b/scripts/lint-agents.sh index 93ab657e..086b386c 100755 --- a/scripts/lint-agents.sh +++ b/scripts/lint-agents.sh @@ -39,6 +39,19 @@ AGENT_DIRS=( REQUIRED_FRONTMATTER=("name" "description" "color") RECOMMENDED_SECTIONS=("Identity" "Core Mission" "Critical Rules") +# The color names convert.sh's resolve_opencode_color() knows, read out of the +# converter rather than copied, so this can never drift from the map that does +# the work. A name that is not in it falls through to grey in the OpenCode +# integration, which reads as a deliberate grey instead of a miss: `slate` and +# `navy` sat there unnoticed across four agents. +KNOWN_COLORS="$( + awk '/^resolve_opencode_color\(\)/{f=1; next} f && /^}/{exit} f' "$SCRIPT_DIR/convert.sh" 2>/dev/null \ + | grep -oE '^ +[a-z-]+\)' | tr -d ' )' +)" +# If the map could not be read, check hex values only rather than rejecting +# every named color on the strength of an empty list. +[[ -n "$KNOWN_COLORS" ]] || echo "WARN could not read resolve_opencode_color() from $SCRIPT_DIR/convert.sh — skipping the color-name check" + errors=0 warnings=0 @@ -102,6 +115,20 @@ lint_file() { fi done + # 2b. The color has to be one the converters can resolve. Checking only that + # the field exists let four agents ship a name nothing maps, and they render + # grey in OpenCode with no warning anywhere. + local color + color="$(get_field color "$file" | tr '[:upper:]' '[:lower:]')" + if [[ -n "$color" && -n "$KNOWN_COLORS" ]] \ + && [[ ! "$color" =~ ^#?[0-9a-f]{6}$ ]] \ + && ! grep -qxF "$color" <<<"$KNOWN_COLORS"; then + echo "ERROR $file: color '${color}' is not a #RRGGBB value or a name the converters know" + echo " known names: $(tr '\n' ' ' <<<"$KNOWN_COLORS")" + echo " use a hex value, or add '${color}' to resolve_opencode_color() in scripts/convert.sh" + errors=$((errors + 1)) + fi + # 3. Check recommended sections (warn only) local body body=$(awk 'BEGIN{n=0} /^---$/{n++; next} n>=2{print}' "$file") diff --git a/scripts/test-convert-outputs.sh b/scripts/test-convert-outputs.sh index d3a22e99..6f322ede 100755 --- a/scripts/test-convert-outputs.sh +++ b/scripts/test-convert-outputs.sh @@ -288,6 +288,35 @@ for tool in TOOLS: report(tool, bad_parse, bad_trip, "parse and round-trip" if fmt in ("yaml-fm", "toml") else "parse, carry their slug, and have their prose file") +# --- Layer A (color): a grey agent should be a grey agent --------------------- +# resolve_opencode_color() maps a name it does not know to #6B7280 and says +# nothing, so a typo or an unlisted name (`slate`, `navy`) reaches users as a +# deliberate-looking grey. Grey is a real choice for the agents that ask for it, +# so the check is not "never grey" — it is "grey only when the source said so". +GREY = "#6B7280" +grey_names = {"gray", "grey", "#6b7280", "6b7280"} +colour_bad = 0 +for f in sorted(glob.glob(os.path.join(OUT, "opencode", "agents", "*.md"))): + slug = os.path.splitext(os.path.basename(f))[0] + entry = src.get(slug) + if entry is None: + continue + try: + emitted = str(frontmatter(open(f, encoding="utf-8").read()).get("color", "")).strip() + source_color = str(frontmatter(open(os.path.join(R, entry[2]), encoding="utf-8").read()) + .get("color", "")).strip().lower() + except Exception: + continue # the strict-parse pass above already reported this + if emitted.upper() == GREY and source_color not in grey_names: + colour_bad += 1 + if colour_bad <= 3: + bad(f"opencode: {slug} asked for color {source_color!r} and got {GREY} — " + f"resolve_opencode_color() does not know that name") +if colour_bad > 3: + bad(f"opencode: ...and {colour_bad-3} more colors silently replaced with grey") +elif not colour_bad: + ok(f"opencode: every agent color resolves; none fell through to {GREY} by accident") + # --- Layer A (split integrity): a source fenced block must survive whole ------- # openclaw is the one tool that splits a single agent body across two files, at # `## ` headings: SOUL.md (persona) / AGENTS.md (operations). A heading inside a