fix(convert): two color names fall through to grey, and nothing checked (#872)

* fix(convert): two color names fall through to grey, and nothing checked

resolve_opencode_color() maps a name it does not recognise to #6B7280 and
says nothing about it. Four agents ask for a name it has never known:

  engineering/engineering-minimal-change-engineer.md   slate
  specialized/operations-manager.md                    slate
  gis/gis-technical-consultant.md                      navy
  specialized/chief-financial-officer.md               navy

All four render grey in the OpenCode integration, which reads as a
deliberate grey rather than a miss — and some agents do choose grey, so
there was nothing to notice.

`slate` and `navy` are now in the map. Values follow the CSS named color
where one exists (navy -> #000080, matching how teal already works) and
Tailwind's 500 shade otherwise (slate -> #64748B, matching gray).

Adding two names does not stop the next one, so two checks now cover it:

  - lint-agents.sh rejects a color that is neither #RRGGBB nor a name the
    converter knows, and prints the names it knows. The list is read out
    of resolve_opencode_color() rather than copied, so it cannot drift
    from the map that does the work. This runs on changed files in agent
    PRs, which is where a new color arrives.

  - test-convert-outputs.sh fails when an opencode output is #6B7280 and
    the source did not ask for grey. Grey stays a legitimate choice; the
    check is "grey only when the source said so".

CONTRIBUTING now says which color values actually work, since the template
just said `colorname or "#hexcode"`.

Verified: all 279 agents pass the new lint rule, and no opencode output
falls through to grey by accident.

* ci(lint): run the whole roster when the linter itself changes

The lint job scopes itself to the agent files a PR touched, which is right
for an agent PR and wrong for a rule change. A new rule lands without ever
having run against the other 278 agents — it either breaks a division
nobody edited or quietly does nothing, and either way CI is silent.

The colour check in the previous commit is the case in point: it changes
scripts/, so the lint workflow's path filter did not even fire, and the
rule would have merged without CI looking at a single agent.

Adds a lint-all job that runs the full-roster lint when scripts/
lint-agents.sh, convert.sh, or lib.sh is part of the diff, and says why it
is skipping when they are not. Those three paths are in the workflow's
trigger list now so the job can fire at all.
This commit is contained in:
Hotragn Pettugani
2026-09-20 18:35:13 -05:00
committed by GitHub
parent d3a3f573e3
commit 1f92396556
5 changed files with 109 additions and 1 deletions
+39
View File
@@ -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
+8 -1
View File
@@ -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
+6
View File
@@ -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
+27
View File
@@ -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")
+29
View File
@@ -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