diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2ed89..aadde15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ Legacy Intelligence Sync history remains in its [archive](https://github.com/ain ## [0.19.0] +### Added + +- Agents and skills accept a tool-neutral `effort:` — `low`, `medium`, `high`, `xhigh`, `max` or `ultra`. Claude Code agents and skills receive it as `effort:` (`ultra` becomes `max`), Codex agents as `model_reasoning_effort`, and the shared `.agents/skills/` tree keeps it as written; Copilot, Cursor, OpenCode, Antigravity and Pi receive no effort key (decision 0015). +- An `effort:` off that scale prints a `WARNING:` line naming the source file and the value, also under `sync --compact` and `init`, renders as absent and leaves sync successful; an empty `effort:` is absent without a warning. + +### Changed + +- A tier no longer sets a reasoning effort in any tool. Codex agents without `effort:` no longer get a tier-derived `model_reasoning_effort` (`xhigh` for `frontier`, `high` for `heavy` or no tier, `medium` for `standard`, `low` for `light`), so Codex's own setting applies and `frontier` and `heavy` render identical Codex agents. + +### Breaking + +- [ ] Every Codex agent that should keep a reasoning effort states it as `effort:` in its source; after sync, `.codex/agents/*.toml` carries `model_reasoning_effort` only for agents whose source sets a valid `effort:`. + ### Fixed - `status --check` no longer reports `.gitignore` patterns as missing when the file has CRLF line endings on Linux and macOS; it decides presence the way the `.gitignore` writer does, so `intelligence init` clears the finding (#45). diff --git a/cli/commands/init.sh b/cli/commands/init.sh index 227e22b..0fbe0d6 100644 --- a/cli/commands/init.sh +++ b/cli/commands/init.sh @@ -296,6 +296,7 @@ manifest="$root/intelligence.yaml" echo "# its stack, and the constraints to respect" echo "# $content_dir/agents/.md a persona (frontmatter: name, description," echo "# tier: frontier|heavy|standard|light," + echo "# effort: low|medium|high|xhigh|max|ultra," echo "# access: full|readonly)" echo "# $content_dir/skills//SKILL.md a procedure invoked as /" echo "# '.intelligence/' is CLI-managed (packages + engine content) — never edit it." diff --git a/cli/tests/e2e-lifecycle.sh b/cli/tests/e2e-lifecycle.sh index 994dab3..500c073 100644 --- a/cli/tests/e2e-lifecycle.sh +++ b/cli/tests/e2e-lifecycle.sh @@ -667,9 +667,10 @@ chk grep -Fq ".agents/rules/big.md renders $override_chars;" "$OUT/agr-override- echo "== every built-in adapter renders every model tier ==" # The expected model comes from the engine's own default table, so a model # bump does not touch this test; what it pins is that each adapter emits the -# tier's resolved model, never an empty one, and Codex's effort ladder. +# tier's resolved model, never an empty one, and that a tier never sets a +# reasoning effort (decision 0015). TIERS="$OUT/model-tiers" -mkdir -p "$TIERS/intelligence/agents" +mkdir -p "$TIERS/intelligence/agents" "$TIERS/intelligence/skills" git -C "$TIERS" init --quiet cat > "$TIERS/intelligence.yaml" < — identical once name and description are set aside. +same_but_identity() { eq "$(grep -Ev '^(name|description) = ' "$1")" "$(grep -Ev '^(name|description) = ' "$2")"; } +chk same_but_identity "$TIERS/.codex/agents/frontier-agent.toml" "$TIERS/.codex/agents/heavy-agent.toml" # A tier nothing resolves still renders, but never silently: one warning per # tool and tier, however many agents carry it. A custom tier the manifest # overrides resolves quietly for that tool. @@ -746,6 +754,113 @@ chknot grep -Fq "tier 'heavy'" "$OUT/tiers-unknown.txt" chk grep -q "^WARNING: no claude model for tier 'haevy'" "$OUT/tiers-compact.txt" chk grep -q "^WARNING: no claude model for tier 'haevy'" "$OUT/tiers-replay.txt" +echo "== effort is written once and rendered where a tool has a field ==" +# Decision 0015: the neutral level reaches Claude Code (the nearest lower level +# it has) and Codex; the shared skills tree keeps it as written; every other +# tool gets no effort key. An off-scale value warns, names its source and is +# rendered as absent without failing the sync; an empty one is absent, silently. +# fm_effort_keys — the frontmatter lines whose key names any effort +# (`effort`, `reasoningEffort`, ...); a body line is the author's text and stays. +fm_effort_keys() { + awk 'NR == 1 && $0 != "---" { exit } /^---$/ { if (++n == 2) exit; next } + index($0, ":") && tolower(substr($0, 1, index($0, ":") - 1)) ~ /effort/' "$1" +} +no_effort_key() { [ -z "$(fm_effort_keys "$1")" ]; } +tiers_agent() { # ... + local name="$1" + shift + { printf -- '---\nname: %s\ndescription: "Agent %s"\n' "$name" "$name" + [ "$#" -eq 0 ] || printf '%s\n' "$@" + printf -- '---\n\n# Agent\n\neffort: a body line keeps its text\n'; } > "$TIERS/intelligence/agents/$name.md" +} +tiers_skill() { # ... + local name="$1" + shift + mkdir -p "$TIERS/intelligence/skills/$name" + { printf -- '---\nname: %s\ndescription: "Skill %s"\n' "$name" "$name" + [ "$#" -eq 0 ] || printf '%s\n' "$@" + printf -- '---\n\n# Skill\n\neffort: a body line keeps its text\n'; } > "$TIERS/intelligence/skills/$name/SKILL.md" +} +tiers_agent effort-xhigh 'tier: light' 'effort: xhigh' +tiers_agent effort-ultra 'effort: ultra' +tiers_agent effort-quoted 'effort: "low"' +tiers_agent effort-typo 'effort: hiigh' +tiers_agent effort-case 'effort: High' +tiers_agent effort-bare 'effort:' +tiers_agent effort-empty 'effort: ""' +# Only the first `effort:` counts, as for every other frontmatter key. +tiers_agent effort-twice 'effort: medium' 'effort: ultra' +printf -- '---\r\nname: effort-crlf\r\ndescription: "CRLF"\r\neffort: max\r\n---\r\n\r\n# Agent\r\n' \ + > "$TIERS/intelligence/agents/effort-crlf.md" +tiers_skill skill-ultra 'effort: ultra' +tiers_skill skill-quoted 'effort: "high"' +tiers_skill skill-typo 'effort: hiigh' +tiers_skill skill-bare 'effort:' +tiers_skill skill-empty 'effort: ""' +tiers_skill skill-plain +RC=0 +(cd "$TIERS" && IS_SUPPRESS_CLI_NOTE=1 bash "$CLI" sync > "$OUT/effort-sync.txt" 2>&1) || RC=$? +[ "$RC" -eq 0 ] || { echo "FAIL: effort sync exited $RC"; cat "$OUT/effort-sync.txt"; fail=1; } +claude_effort() { chk grep -Fqx "effort: $2" "$TIERS/.claude/agents/$1.md"; } +codex_effort() { chk grep -Fqx "model_reasoning_effort = \"$2\"" "$TIERS/.codex/agents/$1.toml"; } +claude_effort effort-xhigh xhigh; codex_effort effort-xhigh xhigh +claude_effort effort-ultra max; codex_effort effort-ultra ultra +claude_effort effort-quoted low; codex_effort effort-quoted low +claude_effort effort-twice medium; codex_effort effort-twice medium +claude_effort effort-crlf max; codex_effort effort-crlf max +# Effort leaves the model alone. +chk grep -Fqx "model: $(default_model claude light)" "$TIERS/.claude/agents/effort-xhigh.md" +chk grep -Fqx "model = \"$(default_model codex light)\"" "$TIERS/.codex/agents/effort-xhigh.toml" +chk eq "$(fm_effort_keys "$TIERS/.claude/agents/effort-twice.md")" "effort: medium" +for agent in effort-typo effort-case effort-bare effort-empty; do + chk no_effort_key "$TIERS/.claude/agents/$agent.md" + chknot grep -q 'model_reasoning_effort' "$TIERS/.codex/agents/$agent.toml" +done +# Tools without a per-agent effort field never receive the key. +for agent in effort-xhigh effort-ultra effort-quoted effort-typo effort-twice effort-crlf; do + for rendered in ".github/agents/$agent.agent.md" ".opencode/agents/$agent.md" \ + ".agents/agents/$agent.md" ".cursor/agents/$agent.md"; do + chk no_effort_key "$TIERS/$rendered" + done +done +# The frontmatter is rewritten, never the body that documents it. +chk grep -Fqx 'effort: a body line keeps its text' "$TIERS/.claude/agents/effort-typo.md" +chk grep -Fqx 'effort: a body line keeps its text' "$TIERS/.cursor/agents/effort-xhigh.md" +# Skills: Claude's level in .claude, the neutral value as written in the shared +# tree, nothing where the tool has no field, and nothing for an off-scale or +# empty value anywhere. +chk grep -Fqx 'effort: max' "$TIERS/.claude/skills/skill-ultra/SKILL.md" +chk grep -Fqx 'effort: ultra' "$TIERS/.agents/skills/skill-ultra/SKILL.md" +chk grep -Fqx 'effort: "high"' "$TIERS/.claude/skills/skill-quoted/SKILL.md" +chk grep -Fqx 'effort: "high"' "$TIERS/.agents/skills/skill-quoted/SKILL.md" +for skill in skill-ultra skill-quoted skill-typo skill-bare skill-empty skill-plain; do + for tree in .github/skills .cursor/skills; do + chk no_effort_key "$TIERS/$tree/$skill/SKILL.md" + done +done +for skill in skill-typo skill-bare skill-empty skill-plain; do + for tree in .claude/skills .agents/skills; do + chk no_effort_key "$TIERS/$tree/$skill/SKILL.md" + chk grep -Fqx 'effort: a body line keeps its text' "$TIERS/$tree/$skill/SKILL.md" + done +done +# One warning per off-scale source, naming it and the value; none for empty. +for source in agents/effort-typo.md:hiigh agents/effort-case.md:High skills/skill-typo/SKILL.md:hiigh; do + chk eq "$(grep -c "^WARNING: .*intelligence/${source%%:*}:[0-9]* effort \"${source##*:}\" is not one of low, medium, high, xhigh, max, ultra" "$OUT/effort-sync.txt")" 1 +done +chknot grep -Eq 'WARN(ING)?: .*(effort-bare|effort-empty|skill-bare|skill-empty|effort-quoted|effort-twice|effort-crlf|skill-quoted)' "$OUT/effort-sync.txt" +chk eq "$(grep -Ec 'WARN(ING)?: .*effort "' "$OUT/effort-sync.txt")" 3 +# `sync --compact`, which `init` runs, keeps only unindented WARNING: lines, so +# the typo reaches it: on a full render, and on the unchanged run that replays it. +(cd "$TIERS" && IS_SUPPRESS_CLI_NOTE=1 bash "$CLI" sync --compact --force > "$OUT/effort-compact.txt" 2>&1) \ + || { echo "FAIL: effort compact sync"; cat "$OUT/effort-compact.txt"; fail=1; } +(cd "$TIERS" && IS_SUPPRESS_CLI_NOTE=1 bash "$CLI" sync --compact > "$OUT/effort-replay.txt" 2>&1) \ + || { echo "FAIL: effort compact replay"; cat "$OUT/effort-replay.txt"; fail=1; } +for report in effort-compact effort-replay; do + chk grep -q '^WARNING: .*intelligence/agents/effort-typo.md:[0-9]* effort "hiigh" is not one of' "$OUT/$report.txt" + chk grep -q '^WARNING: .*intelligence/skills/skill-typo/SKILL.md:[0-9]* effort "hiigh" is not one of' "$OUT/$report.txt" +done + echo "== skills only the owner invokes get Codex's policy at sync ==" # The source states the intent once; Codex's own file is the engine's to write, # for project skills as much as package ones, and an author's own file is kept. diff --git a/cli/tests/unit-engine.sh b/cli/tests/unit-engine.sh index 551ff4f..621460d 100644 --- a/cli/tests/unit-engine.sh +++ b/cli/tests/unit-engine.sh @@ -204,6 +204,87 @@ for ide in claude cursor copilot codex antigravity opencode; do done done +echo "== effort: an independent expected matrix ==" +# map_effort_var is the one mapping every adapter and the skill-copy pass +# render through (decision 0015), so this matrix pins the table itself. +# Columns follow the neutral scale; `-` means the tool receives no effort. +chk eq "$IS_EFFORT_LEVELS" "low medium high xhigh max ultra" +while read -r tool low medium high xhigh max ultra; do + row="$tool:" + for level in $IS_EFFORT_LEVELS; do + map_effort_var "$tool" "$level" + row+=" ${IS_EFFORT:--}" + done + chk eq "$row" "$tool: $low $medium $high $xhigh $max $ultra" +done <<'EOF' +claude low medium high xhigh max max +codex low medium high xhigh max ultra +open low medium high xhigh max ultra +copilot - - - - - - +cursor - - - - - - +opencode - - - - - - +antigravity - - - - - - +pi - - - - - - +EOF +# Off the scale is matched exactly, case and quotes included, and is absent. +for tool in claude codex open; do + for value in "" hiigh High HIGH " high" "high " '"high"' "low medium" ultra-high; do + map_effort_var "$tool" "$value" + chk eq "$tool [$value] -> [$IS_EFFORT]" "$tool [$value] -> []" + done +done +chk eq "$(map_effort claude ultra)" "max" +# The awk passes receive map_effort_var's own answers, never a second table. +effort_map_var claude +chk eq "$IS_EFFORT_MAP" $'low=low\nmedium=medium\nhigh=high\nxhigh=xhigh\nmax=max\nultra=max\n' +effort_map_var copilot +chk eq "$IS_EFFORT_MAP" "" + +echo "== lint_frontmatter_files: an off-scale effort warns once, an empty one never ==" +L="$OUT/lint-effort" +mkdir -p "$L" +printf '%s\n' '---' 'effort: hiigh' 'effort: high' '---' 'effort: a body line' > "$L/typo.md" +printf '%s\n' '---' 'effort: High' '---' > "$L/case.md" +printf '%s\n' '---' 'effort: high' 'effort: hiigh' '---' > "$L/second.md" +printf '%s\n' '---' 'effort:' '---' > "$L/bare.md" +printf '%s\n' '---' 'effort: ""' '---' > "$L/empty.md" +printf '%s\n' '---' 'effort: "xhigh"' '---' > "$L/quoted.md" +printf '%s\r\n' '---' 'effort: ultra' '---' > "$L/crlf.md" +printf '%s\n' 'effort: hiigh' > "$L/nofm.md" +warnings="$(lint_frontmatter_files "$L"/*.md 2>&1 >/dev/null)" +chk eq "$(printf '%s\n' "$warnings" | grep -c 'effort')" 2 +chk eq "$(printf '%s\n' "$warnings" | grep -c "^WARNING: $L/typo.md:2 effort \"hiigh\" is not one of low, medium, high, xhigh, max, ultra")" 1 +chk eq "$(printf '%s\n' "$warnings" | grep -c "^WARNING: $L/case.md:2 effort \"High\" is not one of")" 1 + +echo "== copy_skill_bundle_dirs_for: effort rendered for the tree's tool ==" +K="$OUT/skill-effort" +mkdir -p "$K/src/up" "$K/src/typo" "$K/src/quoted" "$K/src/none/references" +printf '%s\n' '---' 'name: up' 'effort: ultra' 'effort: low' '---' 'effort: a body line' > "$K/src/up/SKILL.md" +printf '%s\n' '---' 'name: typo' 'effort: hiigh' '---' > "$K/src/typo/SKILL.md" +printf '%s\n' '---' 'name: quoted' "effort: 'high'" '---' > "$K/src/quoted/SKILL.md" +printf '%s\n' '---' 'name: none' '---' > "$K/src/none/SKILL.md" +printf '%s\n' '---' 'effort: ultra' '---' > "$K/src/none/references/notes.md" +skill_fm() { awk '/^---$/ { if (++n == 2) exit; next } /^effort:/' "$1"; } +for tool in claude open copilot; do + copy_skill_bundle_dirs_for "$tool" "$K/$tool" "$K/src/up" "$K/src/typo" "$K/src/quoted" "$K/src/none" + chk eq "$(skill_fm "$K/$tool/typo/SKILL.md")" "" + chk eq "$(skill_fm "$K/$tool/none/SKILL.md")" "" + # Only the top-level SKILL.md is a skill's frontmatter; the body and the + # bundled references are the author's text. + chk eq "$(tail -n 1 "$K/$tool/up/SKILL.md")" "effort: a body line" + chk eq "$(skill_fm "$K/$tool/none/references/notes.md")" "effort: ultra" +done +chk eq "$(skill_fm "$K/claude/up/SKILL.md")" "effort: max" +chk eq "$(skill_fm "$K/claude/quoted/SKILL.md")" "effort: 'high'" +chk eq "$(skill_fm "$K/open/up/SKILL.md")" "effort: ultra" +chk eq "$(skill_fm "$K/open/quoted/SKILL.md")" "effort: 'high'" +chk eq "$(skill_fm "$K/copilot/up/SKILL.md")" "" +chk eq "$(skill_fm "$K/copilot/quoted/SKILL.md")" "" +# The plain form renders for the shared open-standard tree. +copy_skill_bundle_dirs "$K/plain" "$K/src/up" "$K/src/typo" +chk eq "$(skill_fm "$K/plain/up/SKILL.md")" "effort: ultra" +chk eq "$(skill_fm "$K/plain/typo/SKILL.md")" "" + echo "== count_matching_files: what find | wc -l reports per directory ==" C="$OUT/counts" mkdir -p "$C/rules/nested" "$C/skills/one/refs" "$C/skills/two" "$C/agents" "$C/empty" diff --git a/decisions/0013-add-a-frontier-model-tier.md b/decisions/0013-add-a-frontier-model-tier.md index c25f107..77a267d 100644 --- a/decisions/0013-add-a-frontier-model-tier.md +++ b/decisions/0013-add-a-frontier-model-tier.md @@ -2,6 +2,7 @@ Date: 2026-10-07 Status: accepted +Superseded by 0015 in part: point 2's separation of `frontier` from `heavy` by Codex reasoning effort, and point 3's effort clause. A tier now selects the model only. ## Context diff --git a/decisions/0015-separate-reasoning-effort-from-the-model-tier.md b/decisions/0015-separate-reasoning-effort-from-the-model-tier.md new file mode 100644 index 0000000..7f29deb --- /dev/null +++ b/decisions/0015-separate-reasoning-effort-from-the-model-tier.md @@ -0,0 +1,85 @@ +# 0015 — Separate reasoning effort from the model tier + +Date: 2026-10-07 +Status: accepted + +## Context + +Decision 0013 let `tier` carry two meanings in one adapter. Every tool mapped it +to a model, and Codex additionally derived `model_reasoning_effort` from it — +`xhigh` for `frontier`, `high` for `heavy`, `medium` and `low` below — because +GPT-6 gives `frontier` and `heavy` the same model and effort was the only way to +tell them apart. + +That coupling left an author no way to say what they meant: + +- An agent could not ask for a model and an effort independently. A cheap model + with deep reasoning, or the strongest model at low effort, had no spelling. +- Claude Code accepts `effort:` on subagents and skills (`low`, `medium`, `high`, + `xhigh`, `max`), so a project wanting it there had to edit generated output. +- The same tier produced different behavior per tool: an effort in Codex, none + in Claude Code, Copilot or OpenCode. A tier name was quietly an effort + setting in one tool only. +- Codex's configuration reference lists `ultra` above `max`; Claude Code stops + at `max`. A neutral scale has to say what a tool without the top level gets. + +## Decision + +1. **`tier` selects the model; `effort` selects the reasoning effort.** Neither + sets the other in any tool. An agent or skill without `effort:` emits no + effort anywhere, and the tool's own setting applies. +2. **One neutral scale**, lowest first: `low`, `medium`, `high`, `xhigh`, `max`, + `ultra`, matched exactly and case-sensitively like `tier` and `access`. +3. **A level a tool lacks maps to the nearest lower level it has.** Claude Code + receives `ultra` as `max`; Codex receives every level. Whether a particular + model supports a level is not the engine's call: a manifest can override the + model, and Claude Code itself falls back to the highest level the active + model supports at or below the one set. +4. **Tools with no per-agent effort field emit nothing.** Copilot, Cursor, + OpenCode, Antigravity and Pi never receive an `effort:` key, in agents or in + their own skill copies. +5. **An off-scale value warns and is otherwise absent.** `effort: hiigh` or + `effort: High` prints one `WARNING:` line naming the source file, the value + and the allowed levels, renders as if `effort:` were not there, and leaves + the exit code alone. The warning comes from the frontmatter lint every sync + already runs over its sources, so it names the source once however many + tools render it. Unlike that lint's indented notes it is a `WARNING:` line, + as decision 0016's is, so `sync --compact` and `init` show it too. +6. **An empty value is an absent effort**: `effort:` or `effort: ""` warns about + nothing, and no generated file carries an empty `effort:` or + `model_reasoning_effort`. +7. **Skills follow the same rules.** `.claude/skills/` receives Claude's level; + the shared `.agents/skills/` tree keeps a valid level as written, as it keeps + `disable-model-invocation`; an empty or off-scale value is removed from every + tree. Codex has no per-skill effort, and nothing is derived for it. + +This supersedes decision 0013's point 2, where Codex separated `frontier` from +`heavy` by reasoning effort, and point 3's clause that an agent without a tier +resolves to `heavy` for its Codex effort. The tier vocabulary and every model +default stay as 0013 set them. + +## Consequences + +- In Codex, `frontier` and `heavy` now render identical agents, as they already + did in Copilot. An agent that relied on the derived `xhigh` or `high` keeps its + behavior by stating `effort:` — or gets Codex's own default without it. +- A Codex agent without `effort:` no longer carries `model_reasoning_effort`, so + a project's Codex configuration decides its effort. +- One spelling reaches every tool that has a native field. Adding a tool's + effort field means one entry in `effort_levels_var`; the vocabulary does not + change. +- A typo in an installed package's agent degrades to the tool default with a + warning instead of failing the sync for everyone who installed it. + +## Rejected + +- **Failing sync on an unknown value.** Packages arrive from other authors and + registries. A strict check would let one typo in someone else's package block + every team that depends on it until its author released a fix. +- **Keeping effort derived from the tier as a default.** It reintroduces the + hidden second meaning this decision removes, in one tool only. +- **Checking per-model level availability in the engine.** The engine does not + know which model runs after a manifest override, and the tools already fall + back on their own. +- **Passing an unsupported level through unchanged.** Claude Code documents no + `ultra`; the nearest lower level is the closest honest rendering. diff --git a/engine/adapters/_template.sh b/engine/adapters/_template.sh index 9045b25..50bd216 100644 --- a/engine/adapters/_template.sh +++ b/engine/adapters/_template.sh @@ -27,6 +27,7 @@ # has_paths(file) — check if file has paths: field # get_model(config, ide, tier) — resolve model from config or default # get_model_default(ide, tier) — hardcoded default for ide:tier +# map_effort(tool, effort) — the tool's level for a neutral effort; "" when none # map_access_to_claude_tools(access) — full->"" (no tools list; inherits all), readonly->restricted # map_access_to_claude_disallowed(access) — readonly->"Write, Edit", full->"" # read_yaml_list(manifest, section) — read a source list from intelligence.yaml diff --git a/engine/adapters/claude.sh b/engine/adapters/claude.sh index aed5f9e..ce4d395 100644 --- a/engine/adapters/claude.sh +++ b/engine/adapters/claude.sh @@ -3,8 +3,9 @@ # Transforms source prompts to .claude/ format # # Rules: copy as-is (paths: frontmatter preserved) -# Skills: copy skill directories in full (SKILL.md + bundled resources) -# Agents: tier -> model, access -> tools/disallowedTools +# Skills: copy skill directories in full (SKILL.md + bundled resources), +# effort -> Claude's effort level +# Agents: tier -> model, effort -> effort, access -> tools/disallowedTools # # Every per-file loop batches its work into one awk process (see the batched # helpers in lib/common.sh): process spawns dominate sync time on Windows. @@ -73,7 +74,7 @@ sync_claude_skills() { done done <<< "$list" [ "${#skill_dirs[@]}" -gt 0 ] || return 0 - copy_skill_bundle_dirs "$output_dir/skills" "${skill_dirs[@]}" + copy_skill_bundle_dirs_for claude "$output_dir/skills" "${skill_dirs[@]}" for d in "${skill_dirs[@]}"; do skill_name="${d%/}" echo " skill: ${skill_name##*/}" @@ -81,7 +82,7 @@ sync_claude_skills() { } # Sync all agents to Claude format: one frontmatter pass resolves -# tier/access for every file, one transform pass writes every output. +# tier/effort/access for every file, one transform pass writes every output. sync_claude_agents() { local repo_root="$1" local config_file="$2" @@ -104,19 +105,22 @@ sync_claude_agents() { load_model_tiers "$config_file" "claude" - local path tier access spec="" report="" - while IFS=$'\x1f' read -r path tier access; do + local path tier access effort spec="" report="" + while IFS=$'\x1f' read -r path tier access effort; do [ -n "$path" ] || continue resolve_model_var "$tier" + map_effort_var claude "$effort" map_access_to_claude_tools_var "$access" map_access_to_claude_disallowed_var "$access" - spec+="$path"$'\x1f'"$IS_MODEL"$'\x1f'"$IS_CLAUDE_TOOLS"$'\x1f'"$IS_CLAUDE_DISALLOWED"$'\n' + spec+="$path"$'\x1f'"$IS_MODEL"$'\x1f'"$IS_CLAUDE_TOOLS"$'\x1f'"$IS_CLAUDE_DISALLOWED"$'\x1f'"$IS_EFFORT"$'\n' report+=" agent: ${path##*/} (tier=$tier -> model=$IS_MODEL, access=$access)"$'\n' - done < <(frontmatter_index "tier,access" "${files[@]}") + done < <(frontmatter_index "tier,access,effort" "${files[@]}") # The transform used to run per file with the values injected via -v; # the batched form carries them per file through the environment (awk -v - # would reprocess backslash escapes). + # would reprocess backslash escapes). The first frontmatter `effort:` line + # becomes Claude's level in place, or goes when there is none; a later + # one goes too, since frontmatter_index read only the first. is_fin_awk_vars IS_MAP_SPEC="$spec" awk "${IS_FIN_V[@]}" -v dst="$output_dir/agents" "$IS_AWK_LIB"' BEGIN { @@ -125,18 +129,25 @@ sync_claude_agents() { for (i = 1; i <= n; i++) { if (recs[i] == "") continue split(recs[i], f, US) - MODEL[f[1]] = f[2]; TOOLS[f[1]] = f[3]; EXTRA[f[1]] = f[4] + MODEL[f[1]] = f[2]; TOOLS[f[1]] = f[3]; EXTRA[f[1]] = f[4]; EFFORT[f[1]] = f[5] } } FNR == 1 { if (out != "") close(out) out = dst "/" base_name(FILENAME) count = 0 + fm = 0 + effort_seen = 0 } /^tier:/ { next } /^access:/ { next } { sub(/\r$/, "") } + FNR == 1 && $0 == "---" { fm = 1 } /^---$/ { count++ } + fm && count == 1 && substr($0, 1, 7) == "effort:" { + if (!effort_seen++ && EFFORT[FILENAME] != "") print fin_line("effort: " EFFORT[FILENAME]) > out + next + } count == 2 && /^---$/ { if (TOOLS[FILENAME] != "") print fin_line("tools: " TOOLS[FILENAME]) > out if (EXTRA[FILENAME] != "") print fin_line("disallowedTools: " EXTRA[FILENAME]) > out diff --git a/engine/adapters/codex.sh b/engine/adapters/codex.sh index b93b87f..fcc2f60 100644 --- a/engine/adapters/codex.sh +++ b/engine/adapters/codex.sh @@ -9,7 +9,9 @@ # Skills: copy skill directories in full to .agents/skills/{name}/ (Codex # reads from $REPO_ROOT/.agents/skills exclusively per official docs) # Agents: -> .codex/agents/{name}.toml (name, description, model, -# model_reasoning_effort, sandbox_mode, developer_instructions) +# model_reasoning_effort, sandbox_mode, developer_instructions). +# model_reasoning_effort comes from `effort:` alone — never from the tier — +# and is omitted without one, so Codex's own setting applies. # # Every per-file loop batches its work into one awk process (see the batched # helpers in lib/common.sh): process spawns dominate sync time on Windows. @@ -97,18 +99,12 @@ sync_codex_agents() { if [ "${#files[@]}" -gt 0 ]; then load_model_tiers "$config_file" "codex" - local path tier access description name effort sandbox spec="" report="" LS=$'\x1e' - while IFS=$'\x1f' read -r path tier access description; do + local path tier access description effort name sandbox spec="" report="" LS=$'\x1e' + while IFS=$'\x1f' read -r path tier access description effort; do [ -n "$path" ] || continue name="${path##*/}"; name="${name%.md}" resolve_model_var "$tier" - case "$tier" in - frontier) effort="xhigh" ;; - heavy|"") effort="high" ;; - standard) effort="medium" ;; - light) effort="low" ;; - *) effort="medium" ;; - esac + map_effort_var codex "$effort" case "$access" in readonly) sandbox="read-only" ;; *) sandbox="workspace-write" ;; @@ -121,7 +117,7 @@ sync_codex_agents() { header="${LS}name = \"$name_escaped\"" header+="${LS}description = \"$description_escaped\"" header+="${LS}model = \"$IS_MODEL\"" - header+="${LS}model_reasoning_effort = \"$effort\"" + [ -z "$IS_EFFORT" ] || header+="${LS}model_reasoning_effort = \"$IS_EFFORT\"" header+="${LS}sandbox_mode = \"$sandbox\"" header+="${LS}" header+="${LS}developer_instructions = \"\"\"" @@ -129,7 +125,7 @@ sync_codex_agents() { spec+="$path"$'\x1f'"$output_dir/$name.toml"$'\x1f'"fence"$'\x1f'"1"$'\x1f'"toml"$'\x1f'"$header"$'\x1f'"${LS}\"\"\""$'\n' report+=" agent: $name.toml"$'\n' count=$((count + 1)) - done < <(frontmatter_index "tier,access,description" "${files[@]}") + done < <(frontmatter_index "tier,access,description,effort" "${files[@]}") emit_wrapped_bodies "$spec" printf '%s' "$report" diff --git a/engine/adapters/copilot.sh b/engine/adapters/copilot.sh index a2d2d2b..8da7e7e 100644 --- a/engine/adapters/copilot.sh +++ b/engine/adapters/copilot.sh @@ -143,7 +143,8 @@ sync_copilot_skills() { local count=0 if [ "${#skill_dirs[@]}" -gt 0 ]; then - copy_skill_bundle_dirs "$output_dir/skills" "${skill_dirs[@]}" + # Copilot has no effort field: the copy carries no `effort:`. + copy_skill_bundle_dirs_for copilot "$output_dir/skills" "${skill_dirs[@]}" for d in "${skill_dirs[@]}"; do skill_name="${d%/}" count=$((count + 1)) diff --git a/engine/adapters/cursor.sh b/engine/adapters/cursor.sh index fc04ee3..1be7bc0 100644 --- a/engine/adapters/cursor.sh +++ b/engine/adapters/cursor.sh @@ -10,6 +10,8 @@ # same rules would cause double-loading and burn the context window. # Skills: copy skill directories in full (SKILL.md + bundled resources) # Agents: strip tier/access/tools/disallowedTools, add model + readonly:true +# Cursor has no per-agent or per-skill effort field (its effort is part of a +# model ID), so `effort:` is stripped from both. # # Every per-file loop batches its work into one awk process (see the batched # helpers in lib/common.sh): process spawns dominate sync time on Windows. @@ -113,7 +115,7 @@ sync_cursor_skills() { done done <<< "$list" [ "${#skill_dirs[@]}" -gt 0 ] || return 0 - copy_skill_bundle_dirs "$output_dir/skills" "${skill_dirs[@]}" + copy_skill_bundle_dirs_for cursor "$output_dir/skills" "${skill_dirs[@]}" for d in "${skill_dirs[@]}"; do skill_name="${d%/}" echo " skill: ${skill_name##*/}" @@ -170,13 +172,16 @@ sync_cursor_agents() { if (out != "") close(out) out = dst "/" base_name(FILENAME) count = 0 + fm = 0 } /^tier:/ { next } /^access:/ { next } /^tools:/ { next } /^disallowedTools:/ { next } { sub(/\r$/, "") } + FNR == 1 && $0 == "---" { fm = 1 } /^---$/ { count++ } + fm && count == 1 && substr($0, 1, 7) == "effort:" { next } count == 2 && /^---$/ { print fin_line("model: " MODEL[FILENAME]) > out if (RO[FILENAME] != "") print fin_line(RO[FILENAME]) > out diff --git a/engine/lib/common.sh b/engine/lib/common.sh index 7b89cb4..7f1b650 100644 --- a/engine/lib/common.sh +++ b/engine/lib/common.sh @@ -460,11 +460,17 @@ copy_md_with_quoted_frontmatter() { # "argument-hint must be a string" — the skill silently disappears from the # picker. Quoting is idempotent: an already-quoted value passes through # untouched. +# +# The same pass renders SKILL.md's `effort:` for the tool that reads the copy +# (map_effort_var): the first `effort:` line becomes the tool's level, and is +# dropped — with every later one — when the tool has no level for it. The +# plain forms render for the shared `open` tree, which keeps a neutral level as +# written and drops only an empty or off-scale one. # Usage: copy_skill_bundle "src/skill/dir" "dest/skill/dir" copy_skill_bundle() { _skill_bundles_reset _skill_bundle_stage "$1" "$2" - _skill_bundles_flush + _skill_bundles_flush open } # copy_skill_bundle_dirs ... — batch form: ONE cp -R @@ -473,8 +479,14 @@ copy_skill_bundle() { # bundles. A later source with the same skill name overwrites file-by-file in # order, exactly like the sequential per-bundle copies did. copy_skill_bundle_dirs() { - local dest_root="$1" - shift + copy_skill_bundle_dirs_for open "$@" +} + +# copy_skill_bundle_dirs_for ... — the batch form +# for a tree one tool reads, rendering `effort:` for that tool. +copy_skill_bundle_dirs_for() { + local effort_tool="$1" dest_root="$2" + shift 2 [ "$#" -gt 0 ] || return 0 local src dest local -a srcs=() @@ -488,7 +500,7 @@ copy_skill_bundle_dirs() { dest="$dest_root/${src##*/}" _skill_bundle_note "$dest" done - _skill_bundles_flush + _skill_bundles_flush "$effort_tool" } _skill_bundles_reset() { @@ -531,6 +543,7 @@ _skill_bundle_note() { esac } +# _skill_bundles_flush _skill_bundles_flush() { [ "${#_SB_DESTS[@]}" -gt 0 ] || return 0 local -a mds=() @@ -540,18 +553,27 @@ _skill_bundles_flush() { done < <(find "${_SB_DESTS[@]}" -type f -name '*.md') _skill_bundles_reset [ "${#mds[@]}" -gt 0 ] || return 0 + effort_map_var "$1" # One awk: quote free-text frontmatter fields in each top-level SKILL.md # (strict-YAML consumers reject unquoted colons; `argument-hint: # [pr-number]` would otherwise arrive as a YAML flow sequence and the - # skill silently vanishes from the picker) and expand layout tokens in - # every bundled markdown file. Quoting is idempotent — already-quoted - # values pass through untouched. The quote list travels through the - # environment, like emit_wrapped_bodies specs. + # skill silently vanishes from the picker), render its `effort:` for the + # tool, and expand layout tokens in every bundled markdown file. Quoting is + # idempotent — already-quoted values pass through untouched. The quote list + # and the effort map travel through the environment, like + # emit_wrapped_bodies specs. The effort key is matched exactly as + # frontmatter_index reads it, so the line rewritten is the one the + # lint judged. is_fin_awk_vars - IS_QUOTE_LIST="$quote_list" awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"' + IS_QUOTE_LIST="$quote_list" IS_EFFORT_MAP="$IS_EFFORT_MAP" awk "${IS_FIN_V[@]}" "$IS_AWK_LIB"' BEGIN { qn = split(ENVIRON["IS_QUOTE_LIST"], QL, "\n") for (qi = 1; qi <= qn; qi++) if (QL[qi] != "") QUOTE[QL[qi]] = 1 + en = split(ENVIRON["IS_EFFORT_MAP"], EL, "\n") + for (ei = 1; ei <= en; ei++) { + eq = index(EL[ei], "=") + if (eq > 0) EFFORT[substr(EL[ei], 1, eq - 1)] = substr(EL[ei], eq + 1) + } } function yamlq(s, out, i, c) { out = "" @@ -572,12 +594,19 @@ _skill_bundles_flush() { flush_file() out_file = FILENAME; line_n = 0 state = (FILENAME in QUOTE) ? "before" : "" + effort_seen = 0 } { sub(/\r$/, "") } state == "before" { if (FNR == 1 && $0 == "---") state = "in_fm" else state = "after" } + state == "in_fm" && FNR > 1 && substr($0, 1, 7) == "effort:" { + if (effort_seen++) next + val = fm_value_strip(substr($0, 8)) + if (!(val in EFFORT)) next + if (EFFORT[val] != val) $0 = "effort: " EFFORT[val] + } state == "in_fm" && FNR > 1 { if ($0 == "---") state = "after" else { @@ -660,7 +689,7 @@ sync_open_skill_dirs() { # copy_skill_bundle_dirs owns the frontmatter-quoting pass, so every # target gets it — not just this open-standard dir. if [ "$count" -gt 0 ]; then - copy_skill_bundle_dirs "$output_dir" "${skill_dirs[@]}" + copy_skill_bundle_dirs_for open "$output_dir" "${skill_dirs[@]}" open_skill_invocation_policies "$output_dir" || return 1 fi @@ -831,16 +860,36 @@ enforce_authored_invocation_policies() { # Print warnings to stderr; do not fail. Strict consumers (Codex CLI) reject # these files with cryptic messages — catching them in sync gives better DX. # Batched: one awk process lints every file passed. +# The engine lints every source once, so this is also where an `effort:` off +# IS_EFFORT_LEVELS is reported — read with frontmatter_index's semantics, +# first occurrence only. Adapters render it as absent and say nothing, so the +# warning names the source exactly once however many tools render it. Unlike +# the lint notes it is an unindented `WARNING:` line, because `sync --compact` +# (and so `init`) keeps only those (decision 0015). # Usage: lint_frontmatter_files "a.md" "b.md" ... lint_frontmatter_files() { [ "$#" -gt 0 ] || return 0 - awk ' - FNR == 1 { in_fm = 0; done = 0 } + awk -v levels="$IS_EFFORT_LEVELS" "$IS_AWK_LIB"' + BEGIN { + nl = split(levels, LV, " ") + allowed = "" + for (li = 1; li <= nl; li++) { + LEVEL[LV[li]] = 1 + allowed = allowed (li > 1 ? ", " : "") LV[li] + } + } + FNR == 1 { in_fm = 0; done = 0; effort_seen = 0 } { sub(/\r$/, "") } done { next } FNR == 1 && $0 != "---" { done = 1; next } FNR == 1 { in_fm = 1; next } in_fm && $0 == "---" { done = 1; next } + in_fm && substr($0, 1, 7) == "effort:" && !effort_seen++ { + effort = fm_value_strip(substr($0, 8)) + if (effort != "" && !(effort in LEVEL)) { + printf "WARNING: %s:%d effort \"%s\" is not one of %s — ignored, so no tool receives an effort from this file\n", FILENAME, FNR, effort, allowed > "/dev/stderr" + } + } in_fm && /^\t/ { printf " WARN: %s:%d leading tab in frontmatter (use spaces)\n", FILENAME, FNR > "/dev/stderr" } @@ -994,8 +1043,8 @@ get_model_default_var() { cursor:heavy) IS_MODEL_DEFAULT="inherit" ;; cursor:standard) IS_MODEL_DEFAULT="inherit" ;; cursor:light) IS_MODEL_DEFAULT="inherit" ;; - # GPT-6 has no mid model, so frontier and heavy share Astra; Codex - # separates them by reasoning effort instead. + # GPT-6 has no mid model, so frontier and heavy share Astra and render + # identically; reasoning effort comes from `effort:`, never the tier. copilot:frontier) IS_MODEL_DEFAULT="gpt-6-astra" ;; copilot:heavy) IS_MODEL_DEFAULT="gpt-6-astra" ;; copilot:standard) IS_MODEL_DEFAULT="gpt-6.1-sol" ;; @@ -1242,6 +1291,64 @@ map_access_to_claude_disallowed() { echo "$IS_CLAUDE_DISALLOWED" } +# --- Effort Mapping --- + +# `effort:` is the tool-neutral reasoning effort an agent or skill asks for, +# lowest first (decision 0015). It is independent of `tier`, which selects the +# model only. A value off this scale — matched exactly, case included — is an +# absent effort: lint_frontmatter_files warns about it and no tool receives +# one, because a typo in another source's package must not block a sync. +IS_EFFORT_LEVELS="low medium high xhigh max ultra" + +# effort_levels_var — set IS_EFFORT_TOOL_LEVELS to the levels the tool's +# native effort field accepts, in scale order; empty for a tool without one. +# `open` is the shared Agent Skills tree (.agents/skills/), which keeps the +# neutral value for the tools that read it. +effort_levels_var() { + case "$1" in + claude) IS_EFFORT_TOOL_LEVELS="low medium high xhigh max" ;; + codex|open) IS_EFFORT_TOOL_LEVELS="$IS_EFFORT_LEVELS" ;; + # Copilot, Cursor, OpenCode, Antigravity and Pi have no per-agent + # effort field: the tool's own setting applies. + *) IS_EFFORT_TOOL_LEVELS="" ;; + esac +} + +# map_effort_var — set IS_EFFORT to the level the tool receives: +# the same level, or the nearest lower one the tool has (`ultra` is `max` for +# Claude Code). Empty when the effort is absent or off the scale, or the tool +# has no field. Which levels a particular model supports is the tool's call: a +# manifest may override the model, and the tools fall back on their own. +map_effort_var() { + local level best="" + IS_EFFORT="" + effort_levels_var "$1" + for level in $IS_EFFORT_LEVELS; do + case " $IS_EFFORT_TOOL_LEVELS " in *" $level "*) best="$level" ;; esac + if [ "$level" = "${2-}" ]; then + IS_EFFORT="$best" + return 0 + fi + done +} + +map_effort() { + map_effort_var "$1" "$2" + echo "$IS_EFFORT" +} + +# effort_map_var — set IS_EFFORT_MAP to one `=` line per +# level the tool maps, so a batched awk pass rewrites `effort:` in place with +# map_effort_var's answers instead of a second copy of the mapping. +effort_map_var() { + local level map="" + for level in $IS_EFFORT_LEVELS; do + map_effort_var "$1" "$level" + [ -z "$IS_EFFORT" ] || map+="$level=$IS_EFFORT"$'\n' + done + IS_EFFORT_MAP="$map" +} + # --- Validation --- # Lexically canonicalize a path: collapse `//`, `.` and `..` by pure string diff --git a/intelligence.yaml b/intelligence.yaml index 92eee8d..0c44bea 100644 --- a/intelligence.yaml +++ b/intelligence.yaml @@ -13,6 +13,7 @@ schema_version: "0.19.0" # its stack, and the constraints to respect # intelligence/agents/.md a persona (frontmatter: name, description, # tier: frontier|heavy|standard|light, +# effort: low|medium|high|xhigh|max|ultra, # access: full|readonly) # intelligence/skills//SKILL.md a procedure invoked as / # '.intelligence/' is CLI-managed (packages + engine content) — never edit it. diff --git a/intelligence/rules/engine.md b/intelligence/rules/engine.md index 24e3f4e..a3638d9 100644 --- a/intelligence/rules/engine.md +++ b/intelligence/rules/engine.md @@ -77,20 +77,21 @@ A per-file loop never spawns a process per file: on Git Bash for Windows one for costs tens of milliseconds, so per-file awk/cp/mv turned large projects into minutes of pure process creation. Batch the loop through the helpers built for this — `finalize_output_files`, `finalize_copy_files`, `frontmatter_index`, -`emit_wrapped_bodies`, `copy_skill_bundle_dirs`, `lint_frontmatter_files` — and -read the manifest through `load_yaml_lists` / `load_yaml_list` / the targets cache so -a section is parsed once per run. The single-file forms remain for cold paths and -project adapters. +`emit_wrapped_bodies`, `copy_skill_bundle_dirs` / `copy_skill_bundle_dirs_for`, +`lint_frontmatter_files` — and read the manifest through `load_yaml_lists` / +`load_yaml_list` / the targets cache so a section is parsed once per run. The +single-file forms remain for cold paths and project adapters. `$(...)` forks too, even around a shell function. A reader on a path every sync takes has a `_var` form that returns through a variable — `read_schema_version_var`, -`engine_version_var`, `get_model_default_var`, `agents_output_path_var`, -`count_matching_files`, `load_model_tiers` — and callers there use it; the printing -form wraps it, so there is still one parser. `read_source_artifact_files` runs its -`find | sort` once per section and replays the answer to later callers while the -engine sets `IS_SOURCE_FILES_MEMO`, which is safe only because no output may land in -a source. Adapters that list sources with in-shell globs spawn nothing; moving them -onto the helper would change their locale-ordered listing to byte order. +`engine_version_var`, `get_model_default_var`, `map_effort_var`, +`agents_output_path_var`, `count_matching_files`, `load_model_tiers` — and callers +there use it; the printing form wraps it, so there is still one parser. +`read_source_artifact_files` runs its `find | sort` once per section and replays +the answer to later callers while the engine sets `IS_SOURCE_FILES_MEMO`, which +is safe only because no output may land in a source. Adapters that list sources +with in-shell globs spawn nothing; moving them onto the helper would change their +locale-ordered listing to byte order. Built-in adapters render concurrently (decision 0011): the plan comes from the contract, so the contract has to tell the truth about order. Adapters whose diff --git a/packages/sync/references/adapters.md b/packages/sync/references/adapters.md index fef4228..b055f23 100644 --- a/packages/sync/references/adapters.md +++ b/packages/sync/references/adapters.md @@ -155,8 +155,10 @@ Use the engine library instead of copying parsers or file-handling logic. | `get_model(config, tool, tier)` | Resolve a `frontier`, `heavy`, `standard` or `light` model, including manifest overrides. | | `get_model_default(tool, tier)` | Read the built-in model default. | | `load_model_tiers(config, tool)` / `resolve_model_var(tier)` | Resolve the four standard tiers once, then map per file without subprocesses. | -| `copy_skill_bundle(src, dest)` | Copy `SKILL.md` and all resources safely, normalize Markdown and quote free-text frontmatter. | +| `map_effort(tool, effort)` / `map_effort_var(tool, effort)` | The level a tool's native effort field receives for a neutral `effort:` (`IS_EFFORT`); empty when the value is absent or off the scale, or the tool has no field. | +| `copy_skill_bundle(src, dest)` | Copy `SKILL.md` and all resources safely, normalize Markdown, quote free-text frontmatter, and keep a valid `effort:` as written, as the shared skills tree does. | | `copy_skill_bundle_dirs(dest_root, src...)` | Batch form: copy every skill directory into `dest_root/` with one copy and one finalize pass. | +| `copy_skill_bundle_dirs_for(tool, dest_root, src...)` | The batch form for a tree one tool reads: `SKILL.md`'s `effort:` becomes that tool's level, or is removed when it has none. | | `sync_open_skill_dirs(root, config, dest)` | Own and populate a shared Agent Skills directory such as `.agents/skills/`, deriving Codex's `agents/openai.yaml` for a skill with `disable-model-invocation: true`. | | `finalize_output_file(file)` | Expand layout tokens and normalize line endings; required for every emitted text file. | | `finalize_output_files(file...)` / `finalize_copy_files(dest, src...)` | Batch forms: finalize in place, or copy-and-finalize into a directory, in one process. | @@ -164,7 +166,7 @@ Use the engine library instead of copying parsers or file-handling logic. | `get_target_field(config, target, field)` | Read another field from the target configuration. | | `repo_rel_link(root, path)` | Produce a stable repo-relative link for a committed output. | -`lint_frontmatter` is run across all inputs by the engine before adapters execute (batched as `lint_frontmatter_files`). It warns about common YAML hazards; adapters should not duplicate that pass. +`lint_frontmatter` is run across all inputs by the engine before adapters execute (batched as `lint_frontmatter_files`). It warns about common YAML hazards and about an `effort:` off the neutral scale, naming the source once; adapters should not duplicate that pass, and render such a value as absent without a second warning. Prefer the batched forms inside per-file loops: a process spawn costs tens of milliseconds on Git Bash for Windows, so one-awk-per-file adapters turn large projects into minutes of process creation. The built-in adapters are the reference for the pattern. @@ -216,7 +218,7 @@ Skills follow the [Agent Skills standard](https://agentskills.io). Copy each ski copy_skill_bundle "$source_skill_dir" "$output_dir/skills/$skill_name" ``` -Do not use plain `cp` for skill bundles. `copy_skill_bundle` preserves non-Markdown assets, avoids materializing symlink targets, normalizes Markdown, expands layout tokens and quotes `description` and `argument-hint` where strict YAML readers require strings. +Do not use plain `cp` for skill bundles. `copy_skill_bundle` preserves non-Markdown assets, avoids materializing symlink targets, normalizes Markdown, expands layout tokens and quotes `description` and `argument-hint` where strict YAML readers require strings. It renders `effort:` as the shared tree does; a tree only one tool reads uses `copy_skill_bundle_dirs_for `, so the copy carries that tool's level or no effort at all. Antigravity, Codex, Pi and OpenCode share `.agents/skills/`. Any adapter writing that open-standard directory must call `sync_open_skill_dirs`; it is the single lifecycle owner for immediate skill subdirectories. @@ -229,11 +231,20 @@ Source agents use tool-neutral fields: name: backend-developer description: "Implements backend features" tier: heavy +effort: high access: full --- ``` -Map `tier` through `get_model`, not a hardcoded model name. Transform `access: full|readonly` into the target tool's native permission model. Preserve the body as the agent's instructions. +Map `tier` through `get_model`, not a hardcoded model name. Map `effort` through `map_effort_var` and emit the tool's own effort key only when it returns a level; never derive an effort from the tier, and never copy the source `effort:` line into a tool without a field. Transform `access: full|readonly` into the target tool's native permission model. Preserve the body as the agent's instructions. + +| Built-in | `effort:` rendering | Evidence, checked 2026-10-07 | +|---|---|---| +| `claude` | `effort:` in agents and skills; `ultra` becomes `max` | [Subagents](https://code.claude.com/docs/en/sub-agents) and [skills](https://code.claude.com/docs/en/skills) accept `low`, `medium`, `high`, `xhigh`, `max`; [model configuration](https://code.claude.com/docs/en/model-config) falls back to the highest level the active model supports at or below the one set | +| `codex` | `model_reasoning_effort` in `.codex/agents/*.toml`, every level; no per-skill effort | The [configuration reference](https://learn.chatgpt.com/docs/config-file/config-reference) lists `low`, `medium`, `high`, `xhigh`, `max`, `ultra`, with availability depending on the model | +| `copilot`, `cursor` | none, in agents or in their own skill copies | No per-agent effort field is rendered, so the tool's own setting applies | +| `opencode`, `antigravity`, `pi` | none in agents | Same; their skills come from the shared tree | +| shared `.agents/skills/` | a valid level as written | The open-standard tree several tools read keeps the neutral value | Built-ins currently emit Claude and Cursor Markdown, Copilot `.agent.md`, Codex TOML, Pi prompt templates and OpenCode Markdown subagents. Read the closest built-in adapter before implementing a new transformation. diff --git a/packages/sync/references/conventions.md b/packages/sync/references/conventions.md index 4977c65..e4e74df 100644 --- a/packages/sync/references/conventions.md +++ b/packages/sync/references/conventions.md @@ -192,10 +192,26 @@ Do not instruct an agent to read rules or restate their content. Claude loads it | `standard` | `sonnet` | `inherit` | `gpt-6.1-sol` | `anthropic/claude-sonnet-5-5` | `flash` | review, validation, analysis | | `light` | `sonnet` | `inherit` | `gpt-6-luna` | `anthropic/claude-sonnet-5-5` | `flash` | lookups and simple formatting | -Codex also receives a reasoning effort per tier — `xhigh`, `high`, `medium`, `low` — which is what separates `frontier` from `heavy` in Codex. Copilot has no effort field, so its `frontier` and `heavy` agents are identical. Claude's Haiku 4.5 is retiring with no successor announced, so `light` shares `standard`'s Sonnet in Claude and OpenCode; Cursor and Antigravity have fewer native levels than there are tiers, so several tiers share one value there too. +A tier selects the model only and never sets a reasoning effort — that is `effort:`, below. Where two tiers share a model they therefore render identical agents: `frontier` and `heavy` in Codex and Copilot. Claude's Haiku 4.5 is retiring with no successor announced, so `light` shares `standard`'s Sonnet in Claude and OpenCode; Cursor and Antigravity have fewer native levels than there are tiers, so several tiers share one value there too. The vocabulary is tool-neutral. Adapters resolve it through `get_model()`. Override a default under `models..` in `intelligence.yaml` only when the project needs a pin; sync reports drift when that override differs from the current default. Another tier name works only where `models..` defines it; elsewhere — a typo, as a rule — the agent renders with an empty `model` and sync warns once per tool and tier. +### Effort mappings + +`effort:` asks for a reasoning effort, independently of the model `tier` selects. The scale, lowest first, is `low`, `medium`, `high`, `xhigh`, `max`, `ultra`, matched exactly. Write it only where an agent or skill needs a level other than the tool's own setting: without it, no tool receives an effort. + +| `effort:` | Claude agents and skills (`effort:`) | Codex agents (`model_reasoning_effort`) | Copilot, Cursor, OpenCode, Antigravity and Pi agents | +|---|---|---|---| +| `low`, `medium`, `high`, `xhigh`, `max` | the same level | the same level | not emitted | +| `ultra` | `max` | `ultra` | not emitted | +| absent, empty or off the scale | not emitted | not emitted | not emitted | + +A level a tool lacks becomes the nearest lower level it has. Which levels a particular model supports is left to the tool: a manifest can override the model, and Claude Code itself falls back to the highest level the active model supports at or below the one requested. Tools without a per-agent effort field keep their own setting. + +A value off the scale — `hiigh`, `High` — never fails a sync. Sync prints a `WARNING:` line naming the file, the value and the allowed levels — `sync --compact` and `init` show it too — and renders the artifact as if `effort:` were absent, because packages arrive from many sources and one author's typo must not block another team. An empty `effort:` is simply absent. + +Skills take the same field. `.claude/skills/` receives Claude's level, the shared `.agents/skills/` tree keeps a valid level as written, and Copilot's and Cursor's skill copies carry none; an empty or off-scale value is removed from every copy. Codex has no per-skill effort. + ### Access mappings `access: full` inherits ordinary tool permissions. `access: readonly` is transformed into the target's native restriction: for example Claude receives read/search/bash tools with writes disallowed, Cursor receives `readonly: true`, and Codex receives a read-only sandbox. @@ -252,7 +268,7 @@ argument-hint: "" 4. Report the changed route and verification. ``` -Standard optional fields (`license`, `compatibility`, `metadata`, `allowed-tools`) and tool extensions pass through unchanged. A tool ignores fields it does not understand. +Standard optional fields (`license`, `compatibility`, `metadata`, `allowed-tools`) and tool extensions pass through unchanged. A tool ignores fields it does not understand. `effort:` is the exception: sync renders it per tool, as [Effort mappings](#effort-mappings) describes. `disable-model-invocation: true` makes a skill one only the owner starts, as a slash command: use it for a procedure that changes versions, the lock or generated outputs, where an agent selecting it unasked is the failure. Claude Code, Cursor and Copilot read the field itself; Codex does not, so sync writes `agents/openai.yaml` with `allow_implicit_invocation: false` beside the skill in `.agents/skills/`. Keep the source to the field. A skill that ships its own `agents/openai.yaml` keeps everything else in it: sync adds the policy when the file sets none, and refuses one that sets it otherwise or not as a plain `allow_implicit_invocation: false` directly under `policy:`. diff --git a/packages/sync/references/onboarding-migration.md b/packages/sync/references/onboarding-migration.md index eaa3861..8f86938 100644 --- a/packages/sync/references/onboarding-migration.md +++ b/packages/sync/references/onboarding-migration.md @@ -50,7 +50,7 @@ Migrate meaning, not tool syntax: | Root `AGENTS.md`, `CLAUDE.md`, `.cursorrules`, Copilot root instructions | Split verified guidance by topic into rules; keep local machine preferences in a gitignored root file only when no adapter representation exists | | `.claude/rules/*.md` | Rule; preserve valid `paths:` | | `.cursor/rules/*.mdc` | Rule; rename `globs:` to `paths:` and remove `alwaysApply:` | -| Claude/Cursor/Copilot agents | Agent; map native model/readonly/tool fields back to `tier:` and `access:` | +| Claude/Cursor/Copilot agents, Codex `.codex/agents/*.toml` | Agent; map native model/readonly/tool fields back to `tier:` and `access:`, and a native effort level (`effort:`, `model_reasoning_effort`) back to `effort:` | | Claude/Cursor/Copilot skills or commands | Skill when the procedure is repeated, multi-step, stable, and verifiable; otherwise a rule or no artifact | | Pi/OpenCode/Codex prompt artifacts | Rule, agent, or skill according to responsibility, after removing tool-specific wrappers | diff --git a/packages/sync/skills/intelligence-update-context/references/agents.md b/packages/sync/skills/intelligence-update-context/references/agents.md index dd2e1d7..25e66f7 100644 --- a/packages/sync/skills/intelligence-update-context/references/agents.md +++ b/packages/sync/skills/intelligence-update-context/references/agents.md @@ -8,7 +8,10 @@ difficulty justifies the most capable and most expensive model. Check the target's actual permission mapping when external read tools are required. If native read-only restrictions exclude those tools, use `full` only with a - clear read-only boundary in the body. + clear read-only boundary in the body. The tier selects the model only: add + `effort:` (`low`, `medium`, `high`, `xhigh`, `max`, `ultra`) only when the + role needs a reasoning effort other than the tool's own setting, as the + authoring conventions' effort mappings describe. 3. Keep the body thin: Expertise, Boundaries, and Build & Verify. Carry its own completion criteria and limitations. Reference constraints by name instead of copying rules, and put reusable procedures in skills. @@ -27,7 +30,8 @@ skills: ``` Quote free-text YAML strings and escape embedded quotes; malformed scalars can -prevent discovery. Verify every skill binding resolves, the tier and access use -the supported vocabulary, and the body defines a role rather than a checklist. +prevent discovery. Verify every skill binding resolves, the tier, effort and +access use the supported vocabulary, and the body defines a role rather than a +checklist; sync warns about an effort it does not recognize and ignores it. Apply the agent size and description limits from the authoring conventions, then return to the shared sync and verification steps. diff --git a/packages/sync/skills/intelligence-update-context/references/skills.md b/packages/sync/skills/intelligence-update-context/references/skills.md index eb25027..436a58a 100644 --- a/packages/sync/skills/intelligence-update-context/references/skills.md +++ b/packages/sync/skills/intelligence-update-context/references/skills.md @@ -26,7 +26,9 @@ agent: --- ``` -Omit optional fields that do not apply. Add the skill to its matching agent's +Omit optional fields that do not apply. Add `effort:` only when the procedure +needs a reasoning effort other than the session's, using the scale in the +authoring conventions' effort mappings. Add the skill to its matching agent's `skills:` list when that agent is project-owned; propose an upstream change for a package-owned agent instead of editing the installed copy. Verify bindings, relative resource links, executable steps, and final success criteria. Apply the