@lead: agent skills #25

Merged
ffaerber merged 2 commits from ai/issue-22 into main 2026-07-04 08:27:13 +02:00

Summary

Scope OpenCode skills per agent so an agent only ever sees the skills relevant to its role. This addresses issue #22's context-window/skill-leak concern: even a skill's one-line summary is now hidden from agents that shouldn't use it, while teammates still know the capability exists (via the roster) and can ask the owning agent. Note the full SKILL.md bodies were already loaded on-demand, never baked into system prompts — this closes the remaining gap where every agent saw every skill's summary.

Changes

  • Add a skills allow-list to the agent registry in agent.yml (pm/qa: gitea-api; senior/lead: gitea-api+node1-ssh; junior: none).
  • Emit the agent's skills as a step output and write a permission.skill block into opencode.json that denies all skills by default and allows only the listed ones.
  • Document per-agent skill scoping and add a Skills column to the agent table in README.md.

Resolves #22 · 🤖 @lead

## Summary Scope OpenCode skills per agent so an agent only ever sees the skills relevant to its role. This addresses issue #22's context-window/skill-leak concern: even a skill's one-line summary is now hidden from agents that shouldn't use it, while teammates still know the capability exists (via the roster) and can ask the owning agent. Note the full `SKILL.md` bodies were already loaded on-demand, never baked into system prompts — this closes the remaining gap where every agent saw every skill's summary. ## Changes - Add a `skills` allow-list to the agent registry in `agent.yml` (pm/qa: `gitea-api`; senior/lead: `gitea-api`+`node1-ssh`; junior: none). - Emit the agent's `skills` as a step output and write a `permission.skill` block into `opencode.json` that denies all skills by default and allows only the listed ones. - Document per-agent skill scoping and add a Skills column to the agent table in `README.md`. --- Resolves #22 · 🤖 @lead
gitea-actions bot added 1 commit 2026-07-04 07:25:31 +02:00

🤖 @lead PR ready for review — @ffaerber please review & merge:

🤖 **@lead** — ✅ PR ready for review — @ffaerber please review & merge: - https://git.ffaerber.duckdns.org/ffaerber/agents/pulls/25

🤖 @lead — activity log (112 entries):

tool calls & reasoning

💬 I'll start by reading AGENTS.md and understanding the current workflow structure.
🔧 read: AGENTS.md
🔧 bash: git branch --show-current && git status && git log --oneline -5
🔧 bash: find /workspace/ffaerber/agents -type f -not -path '*/.git/*' | head -50
🔧 read: .gitea/workflows/agent.yml
💬 Now I understand the full structure. Let me look at the other files and understand the constraints.
🔧 read: .gitea/workflows/ai-agent.yml
🔧 read: README.md
💬 I now have a full understanding. This is a substantial architectural task with several parts. Let me analyze the constraints carefully.

The maintainer wants (from the thread):

  1. Skill prompt optimization — the core ask in the issue: agents without a skill should only see a summary of another agent's capabilities, not the full API details.
  2. Split agent.yml into modular filesagents/shared.yml + agents/pm.yml, senior.yml, etc.
  3. Externalize inline scripts into bash script files for readability.

But there's a critical technical constraint I must verify: can Gitea Actions reusable workflows actually be composed the way the maintainer imagines? Let me think through the reality here, and check the key misconception in the original issue.
🔧 todowrite: 3 todos
💬 Let me reason through the core technical realities before writing anything.

On the skill/context-window concern (the actual issue #22):

The premise in the issue contains a misconception worth correcting. Looking at how skills work in OpenCode (per my system prompt's <available_skills> block): skills are NOT injected in full into every agent's system prompt. What appears is only the name + description (a one-line summary), plus the file location. The full SKILL.md body (the API call details, curl examples, etc.) is only loaded on demand when the agent calls the skill tool to load it.

So the issue's worry — "how the api calls are done should not be included in agent2 system prompt" — is already how it works. The full gitea-api details are NOT in every agent's system prompt; only the one-line description: is. The heavy SKILL.md body loads lazily.

However, there IS a real, legitimate improvement available here, and it matches the maintainer's mental model closely:

Currently in agent.yml, the gitea-api and node1-ssh SKILL.md files are written to ~/.config/opencode/skills/ for every agent regardless of role (steps at lines 218 and 134 run unconditionally when the secrets exist). That means even agents that shouldn't use them get the skill registered. The right fix is to gate which skills each agent gets by role — so the skill (even its one-line summary) only appears for agents who should use it.

Let me verify my understanding of skill loading is correct rather than assert it.
🔧 webfetch: https://opencode.ai/docs/skills (text/html)
💬 This confirms my analysis precisely. Key facts:

  1. Skills are loaded on-demand. Only name + description appear in the skill tool's <available_skills> list. The full SKILL.md body (all the curl/API detail) is NOT in any system prompt — it loads only when the agent calls skill({name}). So the issue's core worry is already handled by design.

  2. The real, actionable improvement matches the maintainer's intent: use OpenCode's permission.skill config to scope which agents even see each skill. With "deny", the skill is hidden from that agent entirely (not even the summary line). This is exactly "agent2 doesn't get agent1's skill details, but can know it exists / ask."

This is the clean, correct, minimal solution — and it fixes a genuine gap: currently every agent gets gitea-api and node1-ssh registered regardless of role. @junior (small YAML changes) probably shouldn't have cross-repo write API access surfaced; @qa/@pm don't push.

Now let me consider the workflow-split and script-externalization asks. There's an important technical constraint here:

  • Gitea reusable workflows (workflow_call) — you can't "combine shared.yml with pm.yml" the way the maintainer imagines. A reusable workflow is one file; you can't merge YAML fragments at call time. Splitting into per-agent workflow files would mean duplicating the shared steps 5×, which contradicts AGENTS.md's "keep changes minimal / one shared workflow" and the whole design ("Change agent behavior once, here"). This ask is based on a misunderstanding of how reusable workflows compose.

  • Script externalization IS feasible and genuinely improves readability — move the big inline run: blocks into .gitea/workflows/scripts/*.sh and call them. This is a real win and safe.

Given AGENTS.md says "keep changes small and independently mergeable" and "one PR each" for independent changes, I should not try to do all three in one giant PR. The skill-scoping change is the actual issue-#22 ask and is self-contained. Let me do that as the focused PR, and clearly flag the workflow-split misconception back to the maintainer rather than build something wrong.

Let me implement the skill-scoping change. First, let me look precisely at where the opencode config is generated and where skills are written.
🔧 todowrite: 2 todos
💬 My plan for the minimal, correct change:

  1. Add a skills field to the agent registry defining which skills each agent may use.
  2. In the "Install opencode + provider config" step, add a permission.skill block to opencode.json that denies-by-default and allows only the agent's own skills — so non-owning agents don't even see the skill's summary line, matching the maintainer's ask ("agent2 doesn't see how it's done; only that agent1 can").
  3. Keep skill emission but note that permission gating is the enforcement layer (skills can still be emitted globally; permission hides them per-agent). This is cleaner than conditionally writing files and keeps the shared workflow simple.

Let me first update the registry to add skills per agent.
🔧 edit: .gitea/workflows/agent.yml
💬 Now I need to emit the agent's allowed skills as a step output so the opencode-config step can read it. Let me add that to the prep step's outputs.
🔧 edit: .gitea/workflows/agent.yml
💬 Now update the opencode config step to add a permission.skill block that denies all skills by default and allows only this agent's listed skills.
🔧 edit: .gitea/workflows/agent.yml
💬 Now let me verify the jq logic produces correct output for each case (empty array = deny-all; non-empty = deny-all + allow-listed).
🔧 bash: echo "junior (empty):"; jq -nc --argjson s '[]' '{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + {($k):"allow"})) )}' echo "senior:"; jq -nc --argjson s '["gitea-api","node1-ssh"]' '{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + …
💬 The permission blocks are correct. Now let me verify the full opencode.json renders as valid JSON for a representative agent (senior), simulating the final jq.
🔧 bash: MCP='{"playwright":{"type":"local","command":["npx","-y","@playwright/mcp@latest","--headless"],"enabled":true}}' PERM='{"skill":{"*":"deny","gitea-api":"allow","node1-ssh":"allow"}}' jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --arg …
💬 Valid config. Now a subtle point: the AGENT_TOKEN is exposed to the agent process regardless of the skill permission (line ~389). That's fine — the skill permission controls whether the skill tool surfaces/loads the SKILL.md, which is the context-window concern in the issue. The token exposure is a separate concern and out of scope.

Now let me update the README's agent table to document the new per-agent skill scoping, since the README says the registry is the source of truth and asks to keep docs in sync.
🔧 edit: README.md
💬 Now let me validate the whole YAML file parses correctly.
🔧 bash: python3 -c "import yaml,sys; yaml.safe_load(open('/workspace/ffaerber/agents/.gitea/workflows/agent.yml')); print('YAML OK')"
🔧 bash: pip install pyyaml -q 2>/dev/null && python3 -c "import yaml; yaml.safe_load(open('/workspace/ffaerber/agents/.gitea/workflows/agent.yml')); print('YAML OK')" || echo "pyyaml unavailable, trying node"; node -e "const fs=require('fs');const …
💬 No YAML parser available. Let me do a targeted structural sanity check on my edits — the key risk is indentation of the inline shell inside run: |. Let me re-read the changed regions.
🔧 read: .gitea/workflows/agent.yml
💬 Now let me validate the embedded /tmp/agents.json heredoc content is valid JSON.
🔧 bash: cat > /tmp/agents.json <<'JSON' { "pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"Product manager"}, "junior": {"model":"ollama-cloud/kimi-k2.7-code:cloud","vision":false,"mo…
💬 All valid. Now let me review the config step region once more to confirm correctness.
🔧 read: .gitea/workflows/agent.yml
💬 The change is clean and self-contained. Now let me commit and push.
🔧 bash: cd /workspace/ffaerber/agents && git add -A && git status --short && git diff --cached --stat
🔧 bash: cd /workspace/ffaerber/agents && git commit -q -m "@lead: issue #22 — per-agent skill scoping via permission.skill" && git push -u origin HEAD:ai/issue-22 && git log --oneline -3
💬 Pushed. Let me update my todos and write the final reply.
🔧 todowrite: 0 todos
💬 I focused this PR on the actual issue #22 ask (skill/context scoping) and deliberately did not bundle in the two side-requests from the thread, because per AGENTS.md independent changes must be separate, independently-mergeable PRs — and one of them rests on a technical misconception I need to flag before building it wrong.

What I built (branch ai/issue-22): per-agent skill scoping.

Important correction to the premise in the issue: the worry — "how the API calls are done gets included in agent2's system prompt" — isn't actually what happens today. OpenCode loads skills on-demand: only a skill's one-line description ever shows in an agent's <available_skills> list; the full SKILL.md body (the curl/API how-to) is fetched only when that agent calls the skill tool. It's never in any system prompt. So the heavy detail already isn't leaking.

What was imperfect: every agent had gitea-api + node1-ssh registered regardless of role, so even @junior saw their summaries. My change fixes exactly your mental model using OpenCode's permission.skill config — deny all skills by default, allow only each agent's listed ones. A denied skill is hidden entirely (name + description gone), so @junior no longer sees gitea-api at all; it just knows from the roster that @senior/@lead can reach the Gitea API and asks them. Registry mapping: @pm/@qagitea-api; @senior/@leadgitea-api + node1-ssh; @junior → none.

On your other two thread requests — not in this PR, and one needs your decision first:

  1. Externalizing inline scripts into .gitea/workflows/scripts/*.sh — feasible and a real readability win. I'll do it as a separate PR (it touches the whole file; keeping it apart keeps this one reviewable).

  2. Splitting agent.yml into agents/shared.yml + pm.yml/senior.yml — this one I want to flag before building. Gitea reusable workflows (workflow_call) can't be composed/merged the way shared.yml + pm.yml implies — there's no YAML-include/merge at call time. Doing it literally means duplicating all the shared steps ~5×, which fights the whole "change agent behavior once, here" design and AGENTS.md's "keep changes minimal." What is achievable and gets you the readability you're after: keep one agent.yml but move each agent's role/prompt config into per-agent files the workflow reads (e.g. agents/<name>.yml data files, not workflow files). Do you want me to go that route, or leave routing as-is and just do the script externalization?

BEGIN_PR_DESCRIPTION

Summary

Scope OpenCode skills per agent so an agent only ever sees the skills relevant to its role. This addresses issue #22's context-window/skill-leak concern: even a skill's one-line summary is now hidden from agents that shouldn't use it, while teammates still know the capability exists (via the roster) and can ask the owning agent. Note the full SKILL.md bodies were already loaded on-demand, never baked into system prompts — this closes the remaining gap where every agent saw every skill's summary.

Changes

  • Add a skills allow-list to the agent registry in agent.yml (pm/qa: gitea-api; senior/lead: gitea-api+node1-ssh; junior: none).
  • Emit the agent's skills as a step output and write a permission.skill block into opencode.json that denies all skills by default and allows only the listed ones.
  • Document per-agent skill scoping and add a Skills column to the agent table in README.md.
    END_PR_DESCRIPTION
🤖 **@lead** — activity log (112 entries): <details> <summary>tool calls & reasoning</summary> 💬 I'll start by reading AGENTS.md and understanding the current workflow structure. 🔧 **read**: `AGENTS.md` 🔧 **bash**: `git branch --show-current && git status && git log --oneline -5` 🔧 **bash**: `find /workspace/ffaerber/agents -type f -not -path '*/.git/*' | head -50` 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Now I understand the full structure. Let me look at the other files and understand the constraints. 🔧 **read**: `.gitea/workflows/ai-agent.yml` 🔧 **read**: `README.md` 💬 I now have a full understanding. This is a substantial architectural task with several parts. Let me analyze the constraints carefully. The maintainer wants (from the thread): 1. **Skill prompt optimization** — the core ask in the issue: agents without a skill should only see a summary of another agent's capabilities, not the full API details. 2. **Split `agent.yml` into modular files** — `agents/shared.yml` + `agents/pm.yml`, `senior.yml`, etc. 3. **Externalize inline scripts** into bash script files for readability. But there's a critical technical constraint I must verify: **can Gitea Actions reusable workflows actually be composed the way the maintainer imagines?** Let me think through the reality here, and check the key misconception in the original issue. 🔧 **todowrite**: `3 todos` 💬 Let me reason through the core technical realities before writing anything. **On the skill/context-window concern (the actual issue #22):** The premise in the issue contains a misconception worth correcting. Looking at how skills work in OpenCode (per my system prompt's `<available_skills>` block): skills are NOT injected in full into every agent's system prompt. What appears is only the **name + description** (a one-line summary), plus the file location. The full `SKILL.md` body (the API call details, curl examples, etc.) is only loaded **on demand** when the agent calls the `skill` tool to load it. So the issue's worry — "how the api calls are done should not be included in agent2 system prompt" — is **already how it works**. The full gitea-api details are NOT in every agent's system prompt; only the one-line `description:` is. The heavy `SKILL.md` body loads lazily. However, there IS a real, legitimate improvement available here, and it matches the maintainer's mental model closely: Currently in `agent.yml`, the `gitea-api` and `node1-ssh` SKILL.md files are written to `~/.config/opencode/skills/` for **every** agent regardless of role (steps at lines 218 and 134 run unconditionally when the secrets exist). That means even agents that shouldn't use them get the skill registered. The right fix is to **gate which skills each agent gets** by role — so the skill (even its one-line summary) only appears for agents who should use it. Let me verify my understanding of skill loading is correct rather than assert it. 🔧 **webfetch**: `https://opencode.ai/docs/skills (text/html)` 💬 This confirms my analysis precisely. Key facts: 1. **Skills are loaded on-demand.** Only `name` + `description` appear in the `skill` tool's `<available_skills>` list. The full `SKILL.md` body (all the curl/API detail) is NOT in any system prompt — it loads only when the agent calls `skill({name})`. So the issue's core worry is already handled by design. 2. **The real, actionable improvement** matches the maintainer's intent: use OpenCode's **`permission.skill`** config to scope which agents even see each skill. With `"deny"`, the skill is hidden from that agent entirely (not even the summary line). This is exactly "agent2 doesn't get agent1's skill details, but can know it exists / ask." This is the clean, correct, minimal solution — and it fixes a genuine gap: currently every agent gets `gitea-api` and `node1-ssh` registered regardless of role. `@junior` (small YAML changes) probably shouldn't have cross-repo write API access surfaced; `@qa`/`@pm` don't push. Now let me consider the **workflow-split** and **script-externalization** asks. There's an important technical constraint here: - **Gitea reusable workflows** (`workflow_call`) — you can't "combine `shared.yml` with `pm.yml`" the way the maintainer imagines. A reusable workflow is one file; you can't merge YAML fragments at call time. Splitting into per-agent workflow files would mean **duplicating** the shared steps 5×, which contradicts AGENTS.md's "keep changes minimal / one shared workflow" and the whole design ("Change agent behavior once, here"). This ask is based on a misunderstanding of how reusable workflows compose. - **Script externalization** IS feasible and genuinely improves readability — move the big inline `run:` blocks into `.gitea/workflows/scripts/*.sh` and call them. This is a real win and safe. Given AGENTS.md says "keep changes small and independently mergeable" and "one PR each" for independent changes, I should **not** try to do all three in one giant PR. The skill-scoping change is the actual issue-#22 ask and is self-contained. Let me do that as the focused PR, and clearly flag the workflow-split misconception back to the maintainer rather than build something wrong. Let me implement the skill-scoping change. First, let me look precisely at where the opencode config is generated and where skills are written. 🔧 **todowrite**: `2 todos` 💬 My plan for the minimal, correct change: 1. Add a `skills` field to the agent registry defining which skills each agent may use. 2. In the "Install opencode + provider config" step, add a `permission.skill` block to `opencode.json` that denies-by-default and allows only the agent's own skills — so non-owning agents don't even see the skill's summary line, matching the maintainer's ask ("agent2 doesn't see how it's done; only that agent1 can"). 3. Keep skill emission but note that permission gating is the enforcement layer (skills can still be emitted globally; permission hides them per-agent). This is cleaner than conditionally writing files and keeps the shared workflow simple. Let me first update the registry to add `skills` per agent. 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now I need to emit the agent's allowed skills as a step output so the opencode-config step can read it. Let me add that to the prep step's outputs. 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now update the opencode config step to add a `permission.skill` block that denies all skills by default and allows only this agent's listed skills. 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now let me verify the jq logic produces correct output for each case (empty array = deny-all; non-empty = deny-all + allow-listed). 🔧 **bash**: `echo "junior (empty):"; jq -nc --argjson s '[]' '{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + {($k):"allow"})) )}' echo "senior:"; jq -nc --argjson s '["gitea-api","node1-ssh"]' '{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + …` 💬 The permission blocks are correct. Now let me verify the full opencode.json renders as valid JSON for a representative agent (senior), simulating the final jq. 🔧 **bash**: `MCP='{"playwright":{"type":"local","command":["npx","-y","@playwright/mcp@latest","--headless"],"enabled":true}}' PERM='{"skill":{"*":"deny","gitea-api":"allow","node1-ssh":"allow"}}' jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --arg …` 💬 Valid config. Now a subtle point: the `AGENT_TOKEN` is exposed to the agent process regardless of the skill permission (line ~389). That's fine — the skill permission controls whether the `skill` tool surfaces/loads the SKILL.md, which is the context-window concern in the issue. The token exposure is a separate concern and out of scope. Now let me update the README's agent table to document the new per-agent skill scoping, since the README says the registry is the source of truth and asks to keep docs in sync. 🔧 **edit**: `README.md` 💬 Now let me validate the whole YAML file parses correctly. 🔧 **bash**: `python3 -c "import yaml,sys; yaml.safe_load(open('/workspace/ffaerber/agents/.gitea/workflows/agent.yml')); print('YAML OK')"` 🔧 **bash**: `pip install pyyaml -q 2>/dev/null && python3 -c "import yaml; yaml.safe_load(open('/workspace/ffaerber/agents/.gitea/workflows/agent.yml')); print('YAML OK')" || echo "pyyaml unavailable, trying node"; node -e "const fs=require('fs');const …` 💬 No YAML parser available. Let me do a targeted structural sanity check on my edits — the key risk is indentation of the inline shell inside `run: |`. Let me re-read the changed regions. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Now let me validate the embedded `/tmp/agents.json` heredoc content is valid JSON. 🔧 **bash**: `cat > /tmp/agents.json <<'JSON' { "pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"Product manager"}, "junior": {"model":"ollama-cloud/kimi-k2.7-code:cloud","vision":false,"mo…` 💬 All valid. Now let me review the config step region once more to confirm correctness. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 The change is clean and self-contained. Now let me commit and push. 🔧 **bash**: `cd /workspace/ffaerber/agents && git add -A && git status --short && git diff --cached --stat` 🔧 **bash**: `cd /workspace/ffaerber/agents && git commit -q -m "@lead: issue #22 — per-agent skill scoping via permission.skill" && git push -u origin HEAD:ai/issue-22 && git log --oneline -3` 💬 Pushed. Let me update my todos and write the final reply. 🔧 **todowrite**: `0 todos` 💬 I focused this PR on the **actual issue #22** ask (skill/context scoping) and deliberately did **not** bundle in the two side-requests from the thread, because per AGENTS.md independent changes must be separate, independently-mergeable PRs — and one of them rests on a technical misconception I need to flag before building it wrong. **What I built (branch `ai/issue-22`):** per-agent skill scoping. **Important correction to the premise in the issue:** the worry — *"how the API calls are done gets included in agent2's system prompt"* — isn't actually what happens today. OpenCode loads skills **on-demand**: only a skill's one-line `description` ever shows in an agent's `<available_skills>` list; the full `SKILL.md` body (the curl/API how-to) is fetched **only** when that agent calls the `skill` tool. It's never in any system prompt. So the heavy detail already isn't leaking. What *was* imperfect: every agent had `gitea-api` + `node1-ssh` registered regardless of role, so even `@junior` saw their summaries. My change fixes exactly your mental model using OpenCode's `permission.skill` config — **deny all skills by default, allow only each agent's listed ones**. A denied skill is hidden entirely (name + description gone), so `@junior` no longer sees `gitea-api` at all; it just knows from the roster that `@senior`/`@lead` can reach the Gitea API and asks them. Registry mapping: `@pm`/`@qa` → `gitea-api`; `@senior`/`@lead` → `gitea-api` + `node1-ssh`; `@junior` → none. **On your other two thread requests — not in this PR, and one needs your decision first:** 1. **Externalizing inline scripts into `.gitea/workflows/scripts/*.sh`** — feasible and a real readability win. I'll do it as a **separate** PR (it touches the whole file; keeping it apart keeps this one reviewable). 2. **Splitting `agent.yml` into `agents/shared.yml` + `pm.yml`/`senior.yml`…** — this one I want to flag before building. Gitea reusable workflows (`workflow_call`) **can't be composed/merged** the way `shared.yml` + `pm.yml` implies — there's no YAML-include/merge at call time. Doing it literally means **duplicating all the shared steps ~5×**, which fights the whole "change agent behavior once, here" design and AGENTS.md's "keep changes minimal." What *is* achievable and gets you the readability you're after: keep one `agent.yml` but move each agent's role/prompt config into per-agent files the workflow reads (e.g. `agents/<name>.yml` data files, not workflow files). Do you want me to go that route, or leave routing as-is and just do the script externalization? BEGIN_PR_DESCRIPTION ## Summary Scope OpenCode skills per agent so an agent only ever sees the skills relevant to its role. This addresses issue #22's context-window/skill-leak concern: even a skill's one-line summary is now hidden from agents that shouldn't use it, while teammates still know the capability exists (via the roster) and can ask the owning agent. Note the full `SKILL.md` bodies were already loaded on-demand, never baked into system prompts — this closes the remaining gap where every agent saw every skill's summary. ## Changes - Add a `skills` allow-list to the agent registry in `agent.yml` (pm/qa: `gitea-api`; senior/lead: `gitea-api`+`node1-ssh`; junior: none). - Emit the agent's `skills` as a step output and write a `permission.skill` block into `opencode.json` that denies all skills by default and allows only the listed ones. - Document per-agent skill scoping and add a Skills column to the agent table in `README.md`. END_PR_DESCRIPTION </details>
Owner

@lead i dont finde the Externalizing inline scripts in .gitea/workflows/scripts/*.sh?

@lead i dont finde the Externalizing inline scripts in .gitea/workflows/scripts/*.sh?

🤖 @lead — updated branch/PR:

Summary

Externalize the inline shell in the shared agent.yml into per-step scripts under .gitea/workflows/scripts/*.sh, as promised on issue #22. agent.yml shrinks from ~723 to ~190 lines and each step becomes a one-line bash "$SCRIPTS/<name>.sh". This is a pure refactor — no behavior change (the two extracted SKILL.md bodies are byte-identical to main; routing/config/publish logic is unchanged).

Because this is a reusable workflow (workflow_call), the runtime checkout is the caller's repo, so the script files aren't otherwise present. The workflow now checks this repo out into .agents-workflow/ (pinned @main) and points $SCRIPTS at it, so callers keep working unchanged.

Changes

  • Add .gitea/workflows/scripts/: route.sh, install-opencode.sh, skill-node1-ssh.sh, skill-gitea-api.sh, fetch-images.sh, fetch-thread.sh, run-agent.sh, build-activity-log.sh, publish.sh.
  • Slim each agent.yml step to run: bash "$SCRIPTS/<name>.sh", passing the same env; keep the tiny 👀/🚀/😕 reaction steps inline.
  • Add a "Fetch shared agent scripts (this repo)" checkout into .agents-workflow/ so scripts exist at runtime in caller repos.
  • Document the new repo layout + the reusable-workflow checkout requirement in README.md.
🤖 **@lead** — updated branch/PR: - https://git.ffaerber.duckdns.org/ffaerber/agents/pulls/25 ## Summary Externalize the inline shell in the shared `agent.yml` into per-step scripts under `.gitea/workflows/scripts/*.sh`, as promised on issue #22. `agent.yml` shrinks from ~723 to ~190 lines and each step becomes a one-line `bash "$SCRIPTS/<name>.sh"`. This is a **pure refactor** — no behavior change (the two extracted `SKILL.md` bodies are byte-identical to `main`; routing/config/publish logic is unchanged). Because this is a reusable workflow (`workflow_call`), the runtime checkout is the *caller's* repo, so the script files aren't otherwise present. The workflow now checks this repo out into `.agents-workflow/` (pinned `@main`) and points `$SCRIPTS` at it, so callers keep working unchanged. ## Changes - Add `.gitea/workflows/scripts/`: `route.sh`, `install-opencode.sh`, `skill-node1-ssh.sh`, `skill-gitea-api.sh`, `fetch-images.sh`, `fetch-thread.sh`, `run-agent.sh`, `build-activity-log.sh`, `publish.sh`. - Slim each `agent.yml` step to `run: bash "$SCRIPTS/<name>.sh"`, passing the same env; keep the tiny 👀/🚀/😕 reaction steps inline. - Add a "Fetch shared agent scripts (this repo)" checkout into `.agents-workflow/` so scripts exist at runtime in caller repos. - Document the new repo layout + the reusable-workflow checkout requirement in `README.md`.

🤖 @lead — activity log (230 entries):

tool calls & reasoning

💬 I'll start by reading AGENTS.md and understanding the current state of the branch and workflow.
🔧 read: AGENTS.md
💬 The maintainer is asking where the "Externalizing inline scripts in .gitea/workflows/scripts/*.sh" change is — because I promised it as a separate PR but never delivered it. Let me check the current state.
🔧 bash: git branch --show-current && git status && git log --oneline -5
💬 The maintainer is right — I promised the script-externalization as a separate PR but never delivered it. The current branch ai/issue-22 only has the skill-scoping change. Now I need to do the script externalization. Let me read the workflow file to understand the inline scripts.
🔧 read: .gitea/workflows/agent.yml
💬 Now I understand the maintainer's ask clearly. They're asking where the promised script-externalization is. I need to do it now. The key constraint from AGENTS.md: this is a shared workflow run in caller repos via workflow_call.

Critical technical concern: When a caller repo does uses: ffaerber/agents/.gitea/workflows/agent.yml@main, only the workflow YAML file is fetched — the caller's own repo is checked out, NOT this agents repo. So external script files at .gitea/workflows/scripts/*.sh would not be present at runtime unless this repo checks itself out. Let me verify this is actually feasible before building it wrong.
🔧 grep: checkout|uses:|actions/checkout
💬 This is the critical issue. The actions/checkout@v4 at line 35 checks out the caller's repo (where the issue lives), not this agents repo. So if I move inline scripts to .gitea/workflows/scripts/*.sh, those files won't exist at runtime in caller repos — the workflow would break for every repo that calls it.

Let me verify how the caller invokes it and confirm my understanding.
🔧 read: .gitea/workflows/ai-agent.yml
💬 Confirmed. This is a genuine, hard technical constraint — exactly the kind of "final call" a tech lead should get right rather than guess.

The problem: In Gitea/GitHub reusable workflows (workflow_call), the reusable workflow file is fetched, but the runtime checkout (actions/checkout@v4 at line 35) checks out the caller's repository. External script files living in this agents repo at .gitea/workflows/scripts/*.sh would NOT exist on disk when the workflow runs in a caller repo (e.g. homelab). Calling bash .gitea/workflows/scripts/foo.sh there would fail with "file not found" — breaking the workflow for all repos.

To externalize scripts safely, the workflow must first check out this agents repo into a subdirectory, then call the scripts from there. Let me verify this pattern works and design it correctly. Let me set up my plan.
🔧 todowrite: 4 todos
💬 Now, per AGENTS.md this externalization should be a separate branch/PR from the skill-scoping change (independently mergeable). The current branch ai/issue-22 already carries the skill-scoping commit. I'll create a new branch off main for the script externalization so the two PRs are independent.

Let me check what's on main vs the current branch.
🔧 bash: git fetch -q origin 2>/dev/null; git log --oneline origin/main -3; echo "---diff stat issue-22 vs main---"; git diff --stat origin/main..ai/issue-22
💬 The skill-scoping PR (#25) is already open from ai/issue-22. The script externalization is a separate independent change. However, the automation opens one PR per pushed branch, and this issue is #25.

Given the maintainer is asking specifically about the missing script-externalization on this issue thread, and it's a distinct change, I'll create a new branch ai/issue-25-scripts off main so it becomes its own independent PR (not entangled with the skill-scoping change already in #25).

Let me create the branch off main.
🔧 bash: git checkout -b ai/issue-25-scripts origin/main && git log --oneline -2
💬 Now let me design the externalization carefully. I'll extract the largest, self-contained inline blocks into scripts. The key candidates (biggest/most complex run: blocks):

  1. Route agent + prepare branch (lines 54-112) — the registry heredoc + routing logic.
  2. Install opencode + config (lines 120-152).
  3. node1-ssh skill (lines 167-236) — huge heredoc.
  4. gitea-api skill (lines 247-356) — huge heredoc.
  5. Run agent (lines 420-519) — big prompt-building block.
  6. Build activity log (lines 525-545).
  7. Publish (lines 562-720) — very large.

Critical constraint again: scripts must be checked out from this repo at runtime. I'll add a checkout step that puts this agents repo into a subdir (e.g. .agents-workflow), then call bash "$AGENTS_DIR/scripts/<name>.sh".

But there's a subtlety: the agent registry heredoc writes /tmp/agents.json which is read by later steps. And scripts need env vars passed through. Externalized scripts inherit the step's env:, so that's fine.

Let me first create the scripts directory and the checkout approach. Let me check the current dir structure.
🔧 bash: ls -la .gitea/workflows/ && ls -la .gitea/
💬 Now, I need to think about scope. Externalizing ALL steps is a large, risky change to a shared workflow. AGENTS.md says keep changes minimal. But the maintainer explicitly wants the script externalization done.

The biggest readability wins with the lowest risk are the two enormous embedded skill heredocs (node1-ssh ~70 lines, gitea-api ~110 lines) and the routing/registry logic. These are the bulk of the file. I'll externalize the most impactful blocks while keeping the change coherent.

Let me design the approach. I'll add one checkout step near the top that fetches this repo's scripts, exposing them via an env var. Let me create the scripts.

First, the routing script (from the "Route agent + prepare branch" step):
🔧 bash: mkdir -p .gitea/workflows/scripts && ls -la .gitea/workflows/scripts
💬 Now let me reconsider the runtime-availability problem more carefully, because it determines the whole design. There's an even simpler concern: does the checkout of the caller repo (line 35) overwrite/clean the working directory such that a second checkout of the agents repo must go in a separate path? Yes — actions/checkout@v4 cleans its target dir. Using path: places it in a subdir without disturbing the caller checkout.

Order matters: the acknowledge step and this new agents-repo checkout can happen before the caller checkout, but the caller checkout at line 35 with default path . could clean the workspace root. To be safe, I'll place the agents-repo checkout after the caller checkout, into a subdir path .agentsactions/checkout only cleans its own target path, not sibling subdirs... actually it cleans the whole workspace by default unless clean: false. Let me be careful: two checkouts into different paths — the second checkout with path: .agents cleans only .agents. The first (caller, path .) cleans root but the .agents dir created later is fine since it runs after.

Actually the risk is reversed: if agents-checkout runs first into .agents, then caller checkout into . may wipe .agents. So agents-checkout must run after the caller checkout. Let me place it right after line 38.

Let me now build the scripts. Starting with the routing script:
🔧 write: .gitea/workflows/scripts/route.sh
💬 Now the opencode config script:
🔧 write: .gitea/workflows/scripts/install-opencode.sh
💬 Now the node1-ssh skill script. The heredoc SKILLET needs to remain intact:
🔧 write: .gitea/workflows/scripts/skill-node1-ssh.sh
💬 Now the gitea-api skill script:
🔧 write: .gitea/workflows/scripts/skill-gitea-api.sh
💬 Now the remaining big blocks: image fetch, thread fetch, run agent, activity log, publish. Let me do image fetch and thread fetch (both self-contained):
🔧 write: .gitea/workflows/scripts/fetch-images.sh
🔧 write: .gitea/workflows/scripts/fetch-thread.sh
💬 Now the "Run agent" script — the big prompt-building block. Note it uses ${{ steps.imgs.outputs.files }} inline in the YAML which is expanded by the Actions runner, not bash. I need to pass that as an env var (FILES) instead so it works in the script.
🔧 write: .gitea/workflows/scripts/run-agent.sh
💬 Now the activity log script and the publish script:
🔧 write: .gitea/workflows/scripts/build-activity-log.sh
🔧 write: .gitea/workflows/scripts/publish.sh
💬 Now I'll rewrite agent.yml to call these scripts. The critical addition: a step that checks out THIS repo's scripts. Since the reusable workflow runs the caller's checkout at line 35, I'll add a checkout of ffaerber/agents into a subdir right after, and set an AGENTS_DIR env / step output pointing to the scripts.

Let me now edit the workflow. First, add the agents-repo checkout step after the caller checkout:
🔧 edit: {"filePath":"/workspace/ffaerber/agents/.gitea/workflows/agent.yml","oldString":" - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n …
💬 The file was modified by the earlier skill-scoping work but this branch is off main, so it has the older content. Wait — I'm on ai/issue-25-scripts off origin/main, but I read the file while on ai/issue-22. Let me re-read the current file on this branch.
🔧 read: .gitea/workflows/agent.yml
💬 Important: this branch (off main) does NOT have the skill-scoping changes (no skills field, no permission.skill). The skill-scoping is in PR #25 (branch ai/issue-22). Since PR #25 isn't merged yet, this branch's route.sh I wrote includes the skills field which main doesn't have.

This is a conflict-management decision. Two options:

  1. Base script-externalization on ai/issue-22 (so it includes skill-scoping) — but then the two aren't independent.
  2. Base on main (independent) — but then my route.sh/install-opencode.sh scripts must match main's content (no skills), else they'd re-introduce/differ.

Per AGENTS.md "independently mergeable," option 2 is correct: the script-externalization PR should be a pure refactor of what's on main, with no behavior change. The skill-scoping (PR #25) then re-applies cleanly on top. But that means my scripts must reflect main's current content (without skills). Let me fix the two scripts to match main exactly (pure refactor, no behavior change).
🔧 edit: .gitea/workflows/scripts/route.sh
🔧 edit: .gitea/workflows/scripts/route.sh
💬 Now fix the header comment in route.sh (remove skills mention):
🔧 edit: .gitea/workflows/scripts/route.sh
💬 Now fix install-opencode.sh to match main (no SKILLS/permission block):
🔧 read: .gitea/workflows/agent.yml
💬 Confirmed — main has no permission/skills block. Let me rewrite install-opencode.sh to match main exactly:
🔧 write: .gitea/workflows/scripts/install-opencode.sh
💬 Now let me verify the rest of main's agent.yml matches what my other scripts extracted. Let me read the remaining sections to compare.
🔧 read: .gitea/workflows/agent.yml
💬 Good — image fetch and thread fetch match my scripts exactly (no skill differences). The skill scripts (node1-ssh, gitea-api) and run-agent/build-activity-log/publish also match main since those blocks were unchanged by the skill-scoping work. Let me verify the two skill blocks and run/publish match main by comparing key lines.
🔧 read: .gitea/workflows/agent.yml
💬 The blocks match. Now let me do the full edits to agent.yml. First, the checkout + route step:
🔧 edit: .gitea/workflows/agent.yml
💬 Now the install-opencode step:
🔧 edit: .gitea/workflows/agent.yml
💬 Now the node1-ssh step. Let me read its current exact bounds:
🔧 read: .gitea/workflows/agent.yml
💬 Now I'll replace the node1-ssh step body. Let me replace from the step header through its end (line 160):
🔧 edit: .gitea/workflows/agent.yml
💬 Now the gitea-api skill step. Let me read its current end.
🔧 read: .gitea/workflows/agent.yml
🔧 read: .gitea/workflows/agent.yml
💬 I'll replace the gitea-api step body (lines 101-212). Let me use the env/run boundary as anchor:
🔧 edit: .gitea/workflows/agent.yml
💬 Now the image fetch, thread fetch, run agent, activity log, and publish steps. Let me read the current state.
🔧 read: .gitea/workflows/agent.yml
💬 Now let me replace the image fetch and thread fetch steps:
🔧 edit: .gitea/workflows/agent.yml
💬 Now the Run agent step. It has the inline ${{ steps.imgs.outputs.files }}. I'll pass it as FILES env and call the script.
🔧 read: .gitea/workflows/agent.yml
💬 Now I need to update run-agent.sh — its ${FILES:-} expansion. But wait: on main, the inline is ${{ steps.imgs.outputs.files }} which expands to something like -f /tmp/att/img_1.png. When passed via env FILES and used as ${FILES:-} unquoted, word-splitting gives the same result. Good.

Let me replace the Run agent step body:
🔧 edit: .gitea/workflows/agent.yml
💬 Now the activity log and publish steps:
🔧 read: .gitea/workflows/agent.yml
💬 Let me read the activity log step header (lines ~150-168) and the rest of publish.
🔧 read: .gitea/workflows/agent.yml
🔧 edit: .gitea/workflows/agent.yml
💬 Now the publish step body. Let me read the full remaining publish body to replace it.
🔧 read: .gitea/workflows/agent.yml
💬 Now replace the entire publish run: | block (lines 167-325) with a script call:
🔧 edit: .gitea/workflows/agent.yml
💬 Now let me make the scripts executable and validate. First mark executable (we invoke via bash "$script" so exec bit isn't strictly required, but good hygiene):
🔧 bash: chmod +x .gitea/workflows/scripts/*.sh && ls -la .gitea/workflows/scripts/
💬 Now validate all scripts with bash -n (syntax check) and the YAML file:
🔧 bash: for f in .gitea/workflows/scripts/*.sh; do bash -n "$f" && echo "OK: $f" || echo "FAIL: $f"; done
💬 All scripts pass syntax check. Now validate the YAML:
🔧 bash: python3 -c "import yaml,sys; yaml.safe_load(open('.gitea/workflows/agent.yml')); print('YAML OK')" 2>&1 || (pip install pyyaml -q 2>&1 | tail -1; python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/agent.yml')); print('YAML OK')"…
💬 No YAML parser available. Let me try installing with --break-system-packages or use another tool.
🔧 bash: pip install pyyaml -q --break-system-packages 2>&1 | tail -1; python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/agent.yml')); print('YAML OK'); print('steps:', len(d['jobs']['agent']['steps']))" 2>&1
💬 YAML parses and has 14 steps (added the agents-repo checkout, so one more than before). Let me verify the full agent.yml is now clean and reads correctly end-to-end.
🔧 read: .gitea/workflows/agent.yml
💬 The file went from 348 lines to 190 lines — the workflow is now much more readable. Let me do a functional test of the trickiest scripts to make sure the refactor is behavior-preserving. Let me test route.sh's registry + routing logic in isolation:
🔧 bash: `cd /tmp/opencode 2>/dev/null || mkdir -p /tmp/opencode && cd /tmp/opencode

Test the registry JSON is valid and routing/jq extraction works for each agent

cat > /tmp/agents.json <<'JSON'
{
"pm": {"model":"ollama-cloud/gemma4:cloud","…💬 Registry and routing logic are behavior-preserving. Now let me verify the extracted script content byte-for-byte matches what was on main (the true test of a pure refactor). Let me diff the git-stored old inline block vs my scripts by checking the original main file. 🔧 **bash**:cd /tmp/opencode

Extract main's agent.yml and compare key blocks. Get main version.

git -C /workspace/ffaerber/agents show origin/main:.gitea/workflows/agent.yml > /tmp/main_agent.yml
echo "main line count: $(wc -l < /tmp/main_agent.yml)"…💬 Now let me verify the SKILL.md heredocs in my scripts match main exactly. I'll extract the node1-ssh SKILL body from main (stripping the 10-space YAML indentation) and compare with what my script produces. 🔧 **bash**:cd /tmp/opencode

Run my skill-node1-ssh.sh with fake secrets in a sandbox HOME and capture the produced SKILL.md,

then compare to main's inline heredoc (de-indented by 10 spaces).

export HOME=/tmp/opencode/fakehome1; rm -rf "$HOME"; mkd…💬 node1-ssh SKILL.md is byte-identical to main. Now the same check for gitea-api: 🔧 **bash**:cd /tmp/opencode
export HOME=/tmp/opencode/fakehome2; rm -rf "$HOME"; mkdir -p "$HOME"
AGENT_TOKEN="TOK" bash /workspace/ffaerber/agents/.gitea/workflows/scripts/skill-gitea-api.sh >/dev/null 2>&1
awk '/cat > ~/.config/opencode/skills/g…💬 Both skill bodies are byte-identical to main — proving this is a pure, behavior-preserving refactor. Let me also verify the skip-when-secrets-missing behavior still works: 🔧 **bash**:cd /tmp/opencode
export HOME=/tmp/opencode/fakehome3; rm -rf "$HOME"; mkdir -p "$HOME"
echo "--- node1 with no secrets ---"
SWARM_HOST="" SWARM_USER="" SSH_PRIV_KEY="" bash /workspace/ffaerber/agents/.gitea/workflows/scripts/skill-node1-ssh…💬 Skip behavior preserved (exit 0, no failure). Note: theset -euin skill scripts with unset vars — but the workflow passes these as env (possibly empty strings), so-uis fine since they're defined. To be safe against truly-unset vars, let me confirm the guards use[ -z "$VAR" ]which underset -u` would error if unset. Since env always sets them (to empty when secret absent), they're defined. That matches main's behavior. Good.

Now let me also verify the install-opencode.sh output matches main's config for a non-browser agent:
🔧 bash: `cd /tmp/opencode

Simulate the jq config generation from install-opencode.sh (pm = no MCP)

MCP='{}'
jq -n --argjson mcp "$MCP" --arg url "http://x" --arg ckey "K" '{
provider: {
ollama: {npm:"@ai-sdk/openai-compatible", options:{base…💬 Everything checks out. Now update README if it documents the workflow structure. Let me check what README says about the workflow file structure. 🔧 **grep**:agent.yml|inline|script|run:|steps|structure💬 Let me read the relevant README sections to see if a structure note should be added. 🔧 **read**:README.md💬 I'll add a short "Repo layout" note documenting the scripts directory, and note the runtime-checkout dependency (important for maintainers to understand why scripts are pinned to@main). 🔧 **edit**: README.md💬 Now let me verify the whole workflow one final time and check git status before committing. 🔧 **bash**:cd /workspace/ffaerber/agents
python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/agent.yml')); print('YAML OK, steps:', len(d['jobs']['agent']['steps']))"
echo "--- git status ---"
git add -A && git status --short💬 Let me do one final review of the diff on agent.yml to make sure no step was accidentally broken and therun:lines are all correct. 🔧 **bash**:cd /workspace/ffaerber/agents
echo "=== all run: lines in agent.yml ==="
grep -n 'run:' .gitea/workflows/agent.yml
echo ""
echo "=== all script references exist? ==="
grep -oE '$SCRIPTS/[a-z-]+.sh' .gitea/workflows/agent.yml | sort -u | w…💬 All 9 externalized scripts are referenced and exist. The 3 remaining inlinerun: |blocks are the tiny ones (acknowledge 👀 at line 29, mark-done 🚀 at 174, mark-failed 😕 at 186) — 3-4 lines each, not worth externalizing (they'd add a$SCRIPTS` env for two curl lines). That keeps the change proportionate.

Let me update the todos and commit.
🔧 todowrite: 1 todos
🔧 bash: `cd /workspace/ffaerber/agents
git commit -q -m "@lead: externalize agent.yml inline scripts into .gitea/workflows/scripts/*.sh

Pure refactor of the shared workflow: move each step's shell into its own file and
call it via bash "$SCRIPTS/…💬 Pushed to branchai/issue-25-scripts. The automation will open a PR for it. 🔧 **todowrite**: 0 todos 💬 You're right — I promised the script-externalization as a separate PR back on issue #22 but never delivered it. It's done now on a **new branch** (ai/issue-25-scripts`, kept separate from the skill-scoping PR #25 so each is independently mergeable per AGENTS.md).

What I did: moved every large inline run: block out of agent.yml and into its own file under .gitea/workflows/scripts/:

  • route.sh, install-opencode.sh, skill-node1-ssh.sh, skill-gitea-api.sh, fetch-images.sh, fetch-thread.sh, run-agent.sh, build-activity-log.sh, publish.sh

agent.yml shrank from 723 → 190 lines; each step is now just run: bash "$SCRIPTS/<name>.sh". (The three trivial 3-line reaction steps — 👀/🚀/😕 — stayed inline; externalizing them would add more boilerplate than it removes.)

The one non-obvious thing I had to solve — and the reason this wasn't trivial: this is a reusable workflow (workflow_call). At runtime the checkout clones the caller's repo (homelab, etc.), not this agents repo — so script files living here would simply not be on disk, and every caller would break with "file not found." So agent.yml now also checks this repo out into .agents-workflow/ (pinned @main, matching the caller's uses: …@main) and points $SCRIPTS there.

Safety — it's a pure refactor, no behavior change. I verified the two big embedded SKILL.md bodies (node1-ssh, gitea-api) are byte-identical to main, the registry/routing jq produces the same outputs, the skip-when-secrets-missing guards still exit 0, and every script passes bash -n. I based this branch on main (not on the skill-scoping branch) precisely so both PRs stay independent and either can merge first.

BEGIN_PR_DESCRIPTION

Summary

Externalize the inline shell in the shared agent.yml into per-step scripts under .gitea/workflows/scripts/*.sh, as promised on issue #22. agent.yml shrinks from ~723 to ~190 lines and each step becomes a one-line bash "$SCRIPTS/<name>.sh". This is a pure refactor — no behavior change (the two extracted SKILL.md bodies are byte-identical to main; routing/config/publish logic is unchanged).

Because this is a reusable workflow (workflow_call), the runtime checkout is the caller's repo, so the script files aren't otherwise present. The workflow now checks this repo out into .agents-workflow/ (pinned @main) and points $SCRIPTS at it, so callers keep working unchanged.

Changes

  • Add .gitea/workflows/scripts/: route.sh, install-opencode.sh, skill-node1-ssh.sh, skill-gitea-api.sh, fetch-images.sh, fetch-thread.sh, run-agent.sh, build-activity-log.sh, publish.sh.
  • Slim each agent.yml step to run: bash "$SCRIPTS/<name>.sh", passing the same env; keep the tiny 👀/🚀/😕 reaction steps inline.
  • Add a "Fetch shared agent scripts (this repo)" checkout into .agents-workflow/ so scripts exist at runtime in caller repos.
  • Document the new repo layout + the reusable-workflow checkout requirement in README.md.
    END_PR_DESCRIPTION
🤖 **@lead** — activity log (230 entries): <details> <summary>tool calls & reasoning</summary> 💬 I'll start by reading AGENTS.md and understanding the current state of the branch and workflow. 🔧 **read**: `AGENTS.md` 💬 The maintainer is asking where the "Externalizing inline scripts in `.gitea/workflows/scripts/*.sh`" change is — because I promised it as a separate PR but never delivered it. Let me check the current state. 🔧 **bash**: `git branch --show-current && git status && git log --oneline -5` 💬 The maintainer is right — I promised the script-externalization as a separate PR but never delivered it. The current branch `ai/issue-22` only has the skill-scoping change. Now I need to do the script externalization. Let me read the workflow file to understand the inline scripts. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Now I understand the maintainer's ask clearly. They're asking where the promised script-externalization is. I need to do it now. The key constraint from AGENTS.md: this is a shared workflow run in caller repos via `workflow_call`. **Critical technical concern:** When a caller repo does `uses: ffaerber/agents/.gitea/workflows/agent.yml@main`, only the workflow YAML file is fetched — the caller's own repo is checked out, NOT this `agents` repo. So external script files at `.gitea/workflows/scripts/*.sh` would **not** be present at runtime unless this repo checks itself out. Let me verify this is actually feasible before building it wrong. 🔧 **grep**: `checkout|uses:|actions/checkout` 💬 This is the critical issue. The `actions/checkout@v4` at line 35 checks out the **caller's** repo (where the issue lives), not this `agents` repo. So if I move inline scripts to `.gitea/workflows/scripts/*.sh`, those files won't exist at runtime in caller repos — the workflow would break for every repo that calls it. Let me verify how the caller invokes it and confirm my understanding. 🔧 **read**: `.gitea/workflows/ai-agent.yml` 💬 Confirmed. This is a genuine, hard technical constraint — exactly the kind of "final call" a tech lead should get right rather than guess. **The problem:** In Gitea/GitHub reusable workflows (`workflow_call`), the reusable workflow file is fetched, but the runtime checkout (`actions/checkout@v4` at line 35) checks out the **caller's** repository. External script files living in *this* `agents` repo at `.gitea/workflows/scripts/*.sh` would NOT exist on disk when the workflow runs in a caller repo (e.g. homelab). Calling `bash .gitea/workflows/scripts/foo.sh` there would fail with "file not found" — breaking the workflow for **all** repos. To externalize scripts safely, the workflow must first check out **this** `agents` repo into a subdirectory, then call the scripts from there. Let me verify this pattern works and design it correctly. Let me set up my plan. 🔧 **todowrite**: `4 todos` 💬 Now, per AGENTS.md this externalization should be a **separate branch/PR** from the skill-scoping change (independently mergeable). The current branch `ai/issue-22` already carries the skill-scoping commit. I'll create a new branch off `main` for the script externalization so the two PRs are independent. Let me check what's on main vs the current branch. 🔧 **bash**: `git fetch -q origin 2>/dev/null; git log --oneline origin/main -3; echo "---diff stat issue-22 vs main---"; git diff --stat origin/main..ai/issue-22` 💬 The skill-scoping PR (#25) is already open from `ai/issue-22`. The script externalization is a separate independent change. However, the automation opens one PR per pushed branch, and this issue is #25. Given the maintainer is asking specifically about the missing script-externalization on *this* issue thread, and it's a distinct change, I'll create a new branch `ai/issue-25-scripts` off `main` so it becomes its own independent PR (not entangled with the skill-scoping change already in #25). Let me create the branch off main. 🔧 **bash**: `git checkout -b ai/issue-25-scripts origin/main && git log --oneline -2` 💬 Now let me design the externalization carefully. I'll extract the largest, self-contained inline blocks into scripts. The key candidates (biggest/most complex `run:` blocks): 1. **Route agent + prepare branch** (lines 54-112) — the registry heredoc + routing logic. 2. **Install opencode + config** (lines 120-152). 3. **node1-ssh skill** (lines 167-236) — huge heredoc. 4. **gitea-api skill** (lines 247-356) — huge heredoc. 5. **Run agent** (lines 420-519) — big prompt-building block. 6. **Build activity log** (lines 525-545). 7. **Publish** (lines 562-720) — very large. **Critical constraint again:** scripts must be checked out from *this* repo at runtime. I'll add a checkout step that puts this `agents` repo into a subdir (e.g. `.agents-workflow`), then call `bash "$AGENTS_DIR/scripts/<name>.sh"`. But there's a subtlety: the agent registry heredoc writes `/tmp/agents.json` which is read by later steps. And scripts need env vars passed through. Externalized scripts inherit the step's `env:`, so that's fine. Let me first create the scripts directory and the checkout approach. Let me check the current dir structure. 🔧 **bash**: `ls -la .gitea/workflows/ && ls -la .gitea/` 💬 Now, I need to think about scope. Externalizing ALL steps is a large, risky change to a shared workflow. AGENTS.md says keep changes minimal. But the maintainer explicitly wants the script externalization done. The biggest readability wins with the lowest risk are the two enormous embedded skill heredocs (node1-ssh ~70 lines, gitea-api ~110 lines) and the routing/registry logic. These are the bulk of the file. I'll externalize the most impactful blocks while keeping the change coherent. Let me design the approach. I'll add one checkout step near the top that fetches this repo's scripts, exposing them via an env var. Let me create the scripts. First, the routing script (from the "Route agent + prepare branch" step): 🔧 **bash**: `mkdir -p .gitea/workflows/scripts && ls -la .gitea/workflows/scripts` 💬 Now let me reconsider the runtime-availability problem more carefully, because it determines the whole design. There's an even simpler concern: does the checkout of the caller repo (line 35) overwrite/clean the working directory such that a second checkout of the agents repo must go in a separate path? Yes — `actions/checkout@v4` cleans its target dir. Using `path:` places it in a subdir without disturbing the caller checkout. Order matters: the acknowledge step and this new agents-repo checkout can happen before the caller checkout, but the caller checkout at line 35 with default path `.` could clean the workspace root. To be safe, I'll place the agents-repo checkout **after** the caller checkout, into a subdir path `.agents` — `actions/checkout` only cleans its own target path, not sibling subdirs... actually it cleans the whole workspace by default unless `clean: false`. Let me be careful: two checkouts into different paths — the second checkout with `path: .agents` cleans only `.agents`. The first (caller, path `.`) cleans root but the `.agents` dir created later is fine since it runs after. Actually the risk is reversed: if agents-checkout runs first into `.agents`, then caller checkout into `.` may wipe `.agents`. So agents-checkout must run **after** the caller checkout. Let me place it right after line 38. Let me now build the scripts. Starting with the routing script: 🔧 **write**: `.gitea/workflows/scripts/route.sh` 💬 Now the opencode config script: 🔧 **write**: `.gitea/workflows/scripts/install-opencode.sh` 💬 Now the node1-ssh skill script. The heredoc `SKILLET` needs to remain intact: 🔧 **write**: `.gitea/workflows/scripts/skill-node1-ssh.sh` 💬 Now the gitea-api skill script: 🔧 **write**: `.gitea/workflows/scripts/skill-gitea-api.sh` 💬 Now the remaining big blocks: image fetch, thread fetch, run agent, activity log, publish. Let me do image fetch and thread fetch (both self-contained): 🔧 **write**: `.gitea/workflows/scripts/fetch-images.sh` 🔧 **write**: `.gitea/workflows/scripts/fetch-thread.sh` 💬 Now the "Run agent" script — the big prompt-building block. Note it uses `${{ steps.imgs.outputs.files }}` inline in the YAML which is expanded by the Actions runner, not bash. I need to pass that as an env var (FILES) instead so it works in the script. 🔧 **write**: `.gitea/workflows/scripts/run-agent.sh` 💬 Now the activity log script and the publish script: 🔧 **write**: `.gitea/workflows/scripts/build-activity-log.sh` 🔧 **write**: `.gitea/workflows/scripts/publish.sh` 💬 Now I'll rewrite `agent.yml` to call these scripts. The critical addition: a step that checks out THIS repo's scripts. Since the reusable workflow runs the caller's checkout at line 35, I'll add a checkout of `ffaerber/agents` into a subdir right after, and set an `AGENTS_DIR` env / step output pointing to the scripts. Let me now edit the workflow. First, add the agents-repo checkout step after the caller checkout: 🔧 **edit**: `{"filePath":"/workspace/ffaerber/agents/.gitea/workflows/agent.yml","oldString":" - uses: actions/checkout@v4\n with:\n fetch-depth: 0\n …` 💬 The file was modified by the earlier skill-scoping work but this branch is off main, so it has the older content. Wait — I'm on `ai/issue-25-scripts` off `origin/main`, but I read the file while on `ai/issue-22`. Let me re-read the current file on this branch. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Important: this branch (off main) does NOT have the skill-scoping changes (no `skills` field, no `permission.skill`). The skill-scoping is in PR #25 (branch `ai/issue-22`). Since PR #25 isn't merged yet, this branch's `route.sh` I wrote includes the `skills` field which main doesn't have. This is a conflict-management decision. Two options: 1. Base script-externalization on `ai/issue-22` (so it includes skill-scoping) — but then the two aren't independent. 2. Base on main (independent) — but then my `route.sh`/`install-opencode.sh` scripts must match main's content (no skills), else they'd re-introduce/differ. Per AGENTS.md "independently mergeable," option 2 is correct: the script-externalization PR should be a pure refactor of what's **on main**, with no behavior change. The skill-scoping (PR #25) then re-applies cleanly on top. But that means my scripts must reflect main's current content (without `skills`). Let me fix the two scripts to match main exactly (pure refactor, no behavior change). 🔧 **edit**: `.gitea/workflows/scripts/route.sh` 🔧 **edit**: `.gitea/workflows/scripts/route.sh` 💬 Now fix the header comment in route.sh (remove skills mention): 🔧 **edit**: `.gitea/workflows/scripts/route.sh` 💬 Now fix install-opencode.sh to match main (no SKILLS/permission block): 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Confirmed — main has no permission/skills block. Let me rewrite install-opencode.sh to match main exactly: 🔧 **write**: `.gitea/workflows/scripts/install-opencode.sh` 💬 Now let me verify the rest of main's agent.yml matches what my other scripts extracted. Let me read the remaining sections to compare. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Good — image fetch and thread fetch match my scripts exactly (no skill differences). The skill scripts (node1-ssh, gitea-api) and run-agent/build-activity-log/publish also match main since those blocks were unchanged by the skill-scoping work. Let me verify the two skill blocks and run/publish match main by comparing key lines. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 The blocks match. Now let me do the full edits to `agent.yml`. First, the checkout + route step: 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now the install-opencode step: 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now the node1-ssh step. Let me read its current exact bounds: 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Now I'll replace the node1-ssh step body. Let me replace from the step header through its end (line 160): 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now the gitea-api skill step. Let me read its current end. 🔧 **read**: `.gitea/workflows/agent.yml` 🔧 **read**: `.gitea/workflows/agent.yml` 💬 I'll replace the gitea-api step body (lines 101-212). Let me use the env/run boundary as anchor: 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now the image fetch, thread fetch, run agent, activity log, and publish steps. Let me read the current state. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Now let me replace the image fetch and thread fetch steps: 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now the Run agent step. It has the inline `${{ steps.imgs.outputs.files }}`. I'll pass it as `FILES` env and call the script. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Now I need to update `run-agent.sh` — its `${FILES:-}` expansion. But wait: on main, the inline is `${{ steps.imgs.outputs.files }}` which expands to something like `-f /tmp/att/img_1.png`. When passed via env `FILES` and used as `${FILES:-}` unquoted, word-splitting gives the same result. Good. Let me replace the Run agent step body: 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now the activity log and publish steps: 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Let me read the activity log step header (lines ~150-168) and the rest of publish. 🔧 **read**: `.gitea/workflows/agent.yml` 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now the publish step body. Let me read the full remaining publish body to replace it. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Now replace the entire publish `run: |` block (lines 167-325) with a script call: 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now let me make the scripts executable and validate. First mark executable (we invoke via `bash "$script"` so exec bit isn't strictly required, but good hygiene): 🔧 **bash**: `chmod +x .gitea/workflows/scripts/*.sh && ls -la .gitea/workflows/scripts/` 💬 Now validate all scripts with `bash -n` (syntax check) and the YAML file: 🔧 **bash**: `for f in .gitea/workflows/scripts/*.sh; do bash -n "$f" && echo "OK: $f" || echo "FAIL: $f"; done` 💬 All scripts pass syntax check. Now validate the YAML: 🔧 **bash**: `python3 -c "import yaml,sys; yaml.safe_load(open('.gitea/workflows/agent.yml')); print('YAML OK')" 2>&1 || (pip install pyyaml -q 2>&1 | tail -1; python3 -c "import yaml; yaml.safe_load(open('.gitea/workflows/agent.yml')); print('YAML OK')"…` 💬 No YAML parser available. Let me try installing with --break-system-packages or use another tool. 🔧 **bash**: `pip install pyyaml -q --break-system-packages 2>&1 | tail -1; python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/agent.yml')); print('YAML OK'); print('steps:', len(d['jobs']['agent']['steps']))" 2>&1` 💬 YAML parses and has 14 steps (added the agents-repo checkout, so one more than before). Let me verify the full agent.yml is now clean and reads correctly end-to-end. 🔧 **read**: `.gitea/workflows/agent.yml` 💬 The file went from 348 lines to 190 lines — the workflow is now much more readable. Let me do a functional test of the trickiest scripts to make sure the refactor is behavior-preserving. Let me test route.sh's registry + routing logic in isolation: 🔧 **bash**: `cd /tmp/opencode 2>/dev/null || mkdir -p /tmp/opencode && cd /tmp/opencode # Test the registry JSON is valid and routing/jq extraction works for each agent cat > /tmp/agents.json <<'JSON' { "pm": {"model":"ollama-cloud/gemma4:cloud","…` 💬 Registry and routing logic are behavior-preserving. Now let me verify the extracted script content byte-for-byte matches what was on main (the true test of a pure refactor). Let me diff the git-stored old inline block vs my scripts by checking the original main file. 🔧 **bash**: `cd /tmp/opencode # Extract main's agent.yml and compare key blocks. Get main version. git -C /workspace/ffaerber/agents show origin/main:.gitea/workflows/agent.yml > /tmp/main_agent.yml echo "main line count: $(wc -l < /tmp/main_agent.yml)"…` 💬 Now let me verify the SKILL.md heredocs in my scripts match main exactly. I'll extract the node1-ssh SKILL body from main (stripping the 10-space YAML indentation) and compare with what my script produces. 🔧 **bash**: `cd /tmp/opencode # Run my skill-node1-ssh.sh with fake secrets in a sandbox HOME and capture the produced SKILL.md, # then compare to main's inline heredoc (de-indented by 10 spaces). export HOME=/tmp/opencode/fakehome1; rm -rf "$HOME"; mkd…` 💬 node1-ssh SKILL.md is byte-identical to main. Now the same check for gitea-api: 🔧 **bash**: `cd /tmp/opencode export HOME=/tmp/opencode/fakehome2; rm -rf "$HOME"; mkdir -p "$HOME" AGENT_TOKEN="TOK" bash /workspace/ffaerber/agents/.gitea/workflows/scripts/skill-gitea-api.sh >/dev/null 2>&1 awk '/cat > ~\/.config\/opencode\/skills\/g…` 💬 Both skill bodies are byte-identical to main — proving this is a pure, behavior-preserving refactor. Let me also verify the skip-when-secrets-missing behavior still works: 🔧 **bash**: `cd /tmp/opencode export HOME=/tmp/opencode/fakehome3; rm -rf "$HOME"; mkdir -p "$HOME" echo "--- node1 with no secrets ---" SWARM_HOST="" SWARM_USER="" SSH_PRIV_KEY="" bash /workspace/ffaerber/agents/.gitea/workflows/scripts/skill-node1-ssh…` 💬 Skip behavior preserved (exit 0, no failure). Note: the `set -eu` in skill scripts with unset vars — but the workflow passes these as env (possibly empty strings), so `-u` is fine since they're defined. To be safe against truly-unset vars, let me confirm the guards use `[ -z "$VAR" ]` which under `set -u` would error if unset. Since env always sets them (to empty when secret absent), they're defined. That matches main's behavior. Good. Now let me also verify the install-opencode.sh output matches main's config for a non-browser agent: 🔧 **bash**: `cd /tmp/opencode # Simulate the jq config generation from install-opencode.sh (pm = no MCP) MCP='{}' jq -n --argjson mcp "$MCP" --arg url "http://x" --arg ckey "K" '{ provider: { ollama: {npm:"@ai-sdk/openai-compatible", options:{base…` 💬 Everything checks out. Now update README if it documents the workflow structure. Let me check what README says about the workflow file structure. 🔧 **grep**: `agent\.yml|inline|script|run:|steps|structure` 💬 Let me read the relevant README sections to see if a structure note should be added. 🔧 **read**: `README.md` 💬 I'll add a short "Repo layout" note documenting the scripts directory, and note the runtime-checkout dependency (important for maintainers to understand why scripts are pinned to `@main`). 🔧 **edit**: `README.md` 💬 Now let me verify the whole workflow one final time and check git status before committing. 🔧 **bash**: `cd /workspace/ffaerber/agents python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/agent.yml')); print('YAML OK, steps:', len(d['jobs']['agent']['steps']))" echo "--- git status ---" git add -A && git status --short` 💬 Let me do one final review of the diff on agent.yml to make sure no step was accidentally broken and the `run:` lines are all correct. 🔧 **bash**: `cd /workspace/ffaerber/agents echo "=== all run: lines in agent.yml ===" grep -n 'run:' .gitea/workflows/agent.yml echo "" echo "=== all script references exist? ===" grep -oE '\$SCRIPTS/[a-z-]+\.sh' .gitea/workflows/agent.yml | sort -u | w…` 💬 All 9 externalized scripts are referenced and exist. The 3 remaining inline `run: |` blocks are the tiny ones (acknowledge 👀 at line 29, mark-done 🚀 at 174, mark-failed 😕 at 186) — 3-4 lines each, not worth externalizing (they'd add a `$SCRIPTS` env for two curl lines). That keeps the change proportionate. Let me update the todos and commit. 🔧 **todowrite**: `1 todos` 🔧 **bash**: `cd /workspace/ffaerber/agents git commit -q -m "@lead: externalize agent.yml inline scripts into .gitea/workflows/scripts/*.sh Pure refactor of the shared workflow: move each step's shell into its own file and call it via bash \"\$SCRIPTS/…` 💬 Pushed to branch `ai/issue-25-scripts`. The automation will open a PR for it. 🔧 **todowrite**: `0 todos` 💬 You're right — I promised the script-externalization as a separate PR back on issue #22 but never delivered it. It's done now on a **new branch** (`ai/issue-25-scripts`, kept separate from the skill-scoping PR #25 so each is independently mergeable per AGENTS.md). **What I did:** moved every large inline `run:` block out of `agent.yml` and into its own file under `.gitea/workflows/scripts/`: - `route.sh`, `install-opencode.sh`, `skill-node1-ssh.sh`, `skill-gitea-api.sh`, `fetch-images.sh`, `fetch-thread.sh`, `run-agent.sh`, `build-activity-log.sh`, `publish.sh` `agent.yml` shrank from **723 → 190 lines**; each step is now just `run: bash "$SCRIPTS/<name>.sh"`. (The three trivial 3-line reaction steps — 👀/🚀/😕 — stayed inline; externalizing them would add more boilerplate than it removes.) **The one non-obvious thing I had to solve** — and the reason this wasn't trivial: this is a **reusable** workflow (`workflow_call`). At runtime the checkout clones the **caller's** repo (homelab, etc.), *not* this `agents` repo — so script files living here would simply not be on disk, and every caller would break with "file not found." So `agent.yml` now also checks **this** repo out into `.agents-workflow/` (pinned `@main`, matching the caller's `uses: …@main`) and points `$SCRIPTS` there. **Safety — it's a pure refactor, no behavior change.** I verified the two big embedded `SKILL.md` bodies (node1-ssh, gitea-api) are **byte-identical** to `main`, the registry/routing jq produces the same outputs, the skip-when-secrets-missing guards still exit 0, and every script passes `bash -n`. I based this branch on `main` (not on the skill-scoping branch) precisely so both PRs stay independent and either can merge first. BEGIN_PR_DESCRIPTION ## Summary Externalize the inline shell in the shared `agent.yml` into per-step scripts under `.gitea/workflows/scripts/*.sh`, as promised on issue #22. `agent.yml` shrinks from ~723 to ~190 lines and each step becomes a one-line `bash "$SCRIPTS/<name>.sh"`. This is a **pure refactor** — no behavior change (the two extracted `SKILL.md` bodies are byte-identical to `main`; routing/config/publish logic is unchanged). Because this is a reusable workflow (`workflow_call`), the runtime checkout is the *caller's* repo, so the script files aren't otherwise present. The workflow now checks this repo out into `.agents-workflow/` (pinned `@main`) and points `$SCRIPTS` at it, so callers keep working unchanged. ## Changes - Add `.gitea/workflows/scripts/`: `route.sh`, `install-opencode.sh`, `skill-node1-ssh.sh`, `skill-gitea-api.sh`, `fetch-images.sh`, `fetch-thread.sh`, `run-agent.sh`, `build-activity-log.sh`, `publish.sh`. - Slim each `agent.yml` step to `run: bash "$SCRIPTS/<name>.sh"`, passing the same env; keep the tiny 👀/🚀/😕 reaction steps inline. - Add a "Fetch shared agent scripts (this repo)" checkout into `.agents-workflow/` so scripts exist at runtime in caller repos. - Document the new repo layout + the reusable-workflow checkout requirement in `README.md`. END_PR_DESCRIPTION </details>
Owner

@lead the last changes are not pressend in the branch ai/issue-22. only 1 commit found

@lead the last changes are not pressend in the branch ai/issue-22. only 1 commit found
gitea-actions bot added 1 commit 2026-07-04 07:54:40 +02:00
Rebased onto the per-agent skill-scoping change so PR #25 carries both:
- route.sh keeps the registry 'skills' allow-list and emits skills as a step output
- install-opencode.sh writes the permission.skill block (deny-all + allow listed)

Pure refactor otherwise: each step's shell moves to its own file, called via
bash "$SCRIPTS/<name>.sh". The two extracted SKILL.md bodies are byte-identical to
main; routing/config/publish behavior is unchanged. Because this is a reusable
workflow (workflow_call) the runtime checkout is the caller's repo, so agent.yml now
checks THIS repo out into .agents-workflow/ (pinned @main) and points $SCRIPTS there.

🤖 @lead — updated branch/PR:

Summary

PR #25 now carries both the per-agent skill scoping and the inline-script externalization on branch ai/issue-22 (previously the externalization was on a separate branch, so only one commit showed here). Together: each agent only ever sees the skills relevant to its role, and the shared agent.yml shrinks from ~723 to ~190 lines with each step calling a dedicated script.

Changes

  • Skill scoping: skills allow-list in the agent registry (route.sh), emitted as a step output; install-opencode.sh writes a permission.skill block that denies all skills by default and allows only the listed ones (pm/qa: gitea-api; senior/lead: gitea-api+node1-ssh; junior: none).
  • Script externalization (pure refactor): move each step's shell into .gitea/workflows/scripts/ (route.sh, install-opencode.sh, skill-node1-ssh.sh, skill-gitea-api.sh, fetch-images.sh, fetch-thread.sh, run-agent.sh, build-activity-log.sh, publish.sh); each step is now bash "$SCRIPTS/<name>.sh". The two extracted SKILL.md bodies are byte-identical to main.
  • Add a checkout of this repo into .agents-workflow/ (pinned @main) so scripts exist at runtime in caller repos (reusable workflow_call).
  • README.md: add a Skills column, document per-agent skill scoping, and describe the new repo layout + reusable-workflow checkout requirement.

Resolves #22 · 🤖 @lead

🤖 **@lead** — updated branch/PR: - https://git.ffaerber.duckdns.org/ffaerber/agents/pulls/25 ## Summary PR #25 now carries **both** the per-agent skill scoping and the inline-script externalization on branch `ai/issue-22` (previously the externalization was on a separate branch, so only one commit showed here). Together: each agent only ever sees the skills relevant to its role, and the shared `agent.yml` shrinks from ~723 to ~190 lines with each step calling a dedicated script. ## Changes - **Skill scoping:** `skills` allow-list in the agent registry (`route.sh`), emitted as a step output; `install-opencode.sh` writes a `permission.skill` block that denies all skills by default and allows only the listed ones (pm/qa: `gitea-api`; senior/lead: `gitea-api`+`node1-ssh`; junior: none). - **Script externalization (pure refactor):** move each step's shell into `.gitea/workflows/scripts/` (`route.sh`, `install-opencode.sh`, `skill-node1-ssh.sh`, `skill-gitea-api.sh`, `fetch-images.sh`, `fetch-thread.sh`, `run-agent.sh`, `build-activity-log.sh`, `publish.sh`); each step is now `bash "$SCRIPTS/<name>.sh"`. The two extracted `SKILL.md` bodies are byte-identical to `main`. - Add a checkout of this repo into `.agents-workflow/` (pinned `@main`) so scripts exist at runtime in caller repos (reusable `workflow_call`). - `README.md`: add a Skills column, document per-agent skill scoping, and describe the new repo layout + reusable-workflow checkout requirement. --- Resolves #22 · 🤖 @lead

🤖 @lead — activity log (142 entries):

tool calls & reasoning

💬 The maintainer is saying the script-externalization changes aren't on ai/issue-22 — only 1 commit found. Let me investigate the branch state.
🔧 bash: git branch --show-current && echo "--- local branches ---" && git branch -a && echo "--- fetch ---" && git fetch -q origin 2>&1 | tail -3
💬 Now I understand. The two changes are on two different branches:

  • ai/issue-22 → skill-scoping (1 commit) — this is PR #25
  • ai/issue-25-scripts → script-externalization

The maintainer expected BOTH on ai/issue-22 (which is what PR #25 tracks). Let me verify.
🔧 bash: echo "=== origin/ai/issue-22 log ===" && git log --oneline origin/ai/issue-22 -5 echo "" echo "=== origin/ai/issue-25-scripts log ===" && git log --oneline origin/ai/issue-25-scripts -5 echo "" echo "=== origin/main log ===" && git log --on…
💬 The situation is clear. ai/issue-22 (PR #25) has only the skill-scoping commit. The script-externalization is on a separate branch ai/issue-25-scripts. The maintainer is looking at PR #25 (ai/issue-22) and doesn't see the script changes there — they expected both changes on ai/issue-22.

The maintainer wants the script-externalization changes ON ai/issue-22. Let me cherry-pick the script-externalization commit onto ai/issue-22 so PR #25 contains both.

Let me first check whether the two commits conflict (skill-scoping touches route.sh/install-opencode.sh content that script-externalization also creates).
🔧 bash: echo "=== files changed by skill-scoping (issue-22) ===" && git diff --stat origin/main..origin/ai/issue-22 echo "" echo "=== files changed by script-externalization (issue-25-scripts) ===" && git diff --stat origin/main..origin/ai/issue-25…
💬 Both touch agent.yml and README.md heavily, so a cherry-pick will conflict. The cleanest approach: combine both changes properly on ai/issue-22 so PR #25 contains the skill-scoping AND the externalized scripts — with the scripts reflecting the skill-scoping behavior (since they'll live together on the same branch).

The critical decision: when both live together, route.sh and install-opencode.sh must contain the skill-scoping logic (the skills field + permission.skill block), because otherwise externalizing would drop the skill-scoping behavior.

Let me look at the current ai/issue-22 agent.yml (skill-scoping applied) and the script-externalization scripts to reconcile them.
🔧 bash: git checkout ai/issue-22 2>&1 | tail -2 echo "=== current agent.yml line count on issue-22 ===" wc -l .gitea/workflows/agent.yml
🔧 todowrite: 5 todos
💬 Let me approach this by cherry-picking the script-externalization commit onto ai/issue-22 and resolving conflicts. This preserves the git history cleanly and lets me reconcile the two changes.
🔧 bash: git cherry-pick 2c8f290 2>&1 | tail -20
💬 README.md auto-merged. Let me look at the conflict in agent.yml.
🔧 bash: git status --short && echo "--- scripts created? ---" && ls .gitea/workflows/scripts/ 2>/dev/null
💬 The scripts were created. The conflict is only in agent.yml. The script-externalization version of agent.yml (the "theirs" side) is the slimmed-down version. I want to take the externalized agent.yml structure but ensure the scripts contain the skill-scoping logic.

The strategy: take the script-externalized agent.yml (slim version), then patch route.sh + install-opencode.sh to add the skill-scoping behavior that was in the skill-scoping commit. Let me resolve the conflict by taking "theirs" (the externalized version).
🔧 bash: git checkout --theirs .gitea/workflows/agent.yml && git add .gitea/workflows/agent.yml && echo "resolved agent.yml to externalized version" && wc -l .gitea/workflows/agent.yml
💬 Now I need to check the README conflict resolution and the agent.yml — but critically I need to reconcile the skill-scoping into the externalized scripts. Let me first check the README merge result and the agent.yml current state to see what skill-scoping bits were lost.
🔧 bash: echo "=== README conflict markers? ===" && grep -n '<<<<<<<\|>>>>>>>\|=======' README.md || echo "no conflicts in README" echo "" echo "=== agent.yml: does it reference skills/permission/OC_SKILLS? ===" grep -n 'skill\|permission\|OC_SKILLS…
💬 So the externalized agent.yml lost the skill-scoping (the skills field in route registry, the OC_SKILLS output emission, and the permission.skill block in install-opencode). I need to re-add these to the externalized scripts. Let me examine what the skill-scoping commit actually changed in agent.yml, so I can port that logic into route.sh and install-opencode.sh.
🔧 bash: git show a79b49b -- .gitea/workflows/agent.yml
💬 Now I understand exactly what needs to be ported. Let me look at the current externalized route.sh and install-opencode.sh and add the skill-scoping logic.
🔧 read: .gitea/workflows/scripts/route.sh
💬 Now let me add the skill-scoping into route.sh (registry skills field + skills output emission).
🔧 edit: .gitea/workflows/scripts/route.sh
🔧 edit: .gitea/workflows/scripts/route.sh
💬 Now install-opencode.sh. Let me read it.
🔧 read: .gitea/workflows/scripts/install-opencode.sh
🔧 edit: .gitea/workflows/scripts/install-opencode.sh
🔧 edit: .gitea/workflows/scripts/install-opencode.sh
💬 Now the install-opencode step in agent.yml must pass SKILLS env. Let me check the current install-opencode step in the externalized agent.yml.
🔧 read: .gitea/workflows/agent.yml
🔧 read: .gitea/workflows/agent.yml
💬 Now let me find the install-opencode step (the one that calls install-opencode.sh).
🔧 bash: grep -n 'install-opencode\|Install opencode\|OLLAMA_CLOUD_API_KEY\|SKILLS' .gitea/workflows/agent.yml
🔧 read: .gitea/workflows/agent.yml
🔧 edit: .gitea/workflows/agent.yml
💬 Now let me check the README merge result to make sure both the skill-scoping doc and the repo-layout doc are present and coherent.
🔧 bash: grep -n -i 'skill\|repo layout\|scripts/\|\.agents-workflow' README.md | head -40
💬 The README auto-merge cleanly combined both. The Skills column (from skill-scoping) and the Repo layout section (from script-externalization) are both present. Let me verify the README table has the Skills column populated correctly.
🔧 read: README.md
💬 README is fully reconciled. Now let me complete the cherry-pick and validate everything.
🔧 bash: git add -A && git status --short
🔧 todowrite: 2 todos
💬 Now let me validate: bash syntax on all scripts, YAML parse, and test the skill-scoping logic in route.sh + install-opencode.sh.
🔧 bash: echo "=== bash -n on all scripts ===" for f in .gitea/workflows/scripts/*.sh; do bash -n "$f" && echo "OK: $f" || echo "FAIL: $f"; done echo "" echo "=== YAML parse ===" python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/agen…
💬 All scripts pass syntax, YAML is valid with 14 steps. Now let me test the skill-scoping logic produces the correct permission.skill block for each agent, and that the combined config matches the standalone skill-scoping PR's output.
🔧 bash: # Test route.sh registry + skills extraction for each agent cat > /tmp/agents.json <<'JSON' { "pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"PM"}, "junior": {"model":"ollama…
💬 The skill-scoping logic produces correct permission blocks for all agents. Now let me verify the full install-opencode.sh generates a valid opencode.json end-to-end (simulate for lead).
🔧 bash: export HOME=/tmp/oc-test; rm -rf "$HOME"; mkdir -p "$HOME" export GITHUB_PATH=/tmp/ghpath; : > "$GITHUB_PATH" export OLLAMA_URL="http://ornith:11434" export OLLAMA_CLOUD_API_KEY="CKEY" export NAME="lead" export SKILLS='["gitea-api","node1-s…
💬 The combined config is valid and includes the permission.skill block. Now let me verify the extracted skill script bodies are still byte-identical to main (the pure-refactor guarantee), then complete the cherry-pick.
🔧 bash: `# Verify node1-ssh + gitea-api SKILL.md bodies are byte-identical to main
git show origin/main:.gitea/workflows/agent.yml > /tmp/main_agent.yml

export HOME=/tmp/sk1; rm -rf "$HOME"; mkdir -p "$HOME"
SWARM_HOST="h" SWARM_USER="u" SSH_PRIV_K…💬 All scripts work correctly and skip gracefully. Now let me complete the cherry-pick with a clear commit message describing the reconciliation. 🔧 **bash**:GIT_EDITOR=true git cherry-pick --continue 2>&1 | tail -5
echo "--- amend message to reflect reconciliation ---"
git commit --amend -q -m "@lead: externalize agent.yml inline scripts into .gitea/workflows/scripts/*.sh

Rebased onto the per-…💬 Nowai/issue-22has BOTH commits. Let me do a final verification of the full agent.yml, then push. 🔧 **bash**:echo "=== all script references exist? ==="
grep -oE '$SCRIPTS/[a-z-]+.sh' .gitea/workflows/agent.yml | sort -u | while read s; do
f=".gitea/workflows/scripts/${s#$SCRIPTS/}"
[ -f "$f" ] && echo "OK: $s" || echo "MISSING: $s"
done
ec…💬 Everything is in place. Both changes (skill-scoping + script-externalization) are now onai/issue-22as two commits. Let me push. 🔧 **bash**:git push origin HEAD:ai/issue-22 2>&1 | tail -8
echo "--- verify remote now has 2 commits ---"
git fetch -q origin && git log --oneline origin/ai/issue-22 -4💬 Pushed.ai/issue-22(PR #25) now has **both** commits. 🔧 **todowrite**:0 todos 💬 Fixed. You were right — the script-externalization was sitting on a **separate** branch (ai/issue-25-scripts), so PR #25 (which tracks ai/issue-22`) only showed the one skill-scoping commit. I'd split them into two independent PRs; you wanted both in PR #25.

What I did: cherry-picked the script-externalization commit onto ai/issue-22 and reconciled the overlap so nothing was lost. ai/issue-22 now has 2 commits:

  1. a79b49b — per-agent skill scoping (permission.skill)
  2. 36ba91c — externalize inline scripts into .gitea/workflows/scripts/*.sh

The one thing that needed care: both changes edit agent.yml, so when the scripts got externalized I had to make sure the skill-scoping logic moved into the scripts rather than being dropped:

  • route.sh keeps the registry skills allow-list and emits skills as a step output.
  • install-opencode.sh writes the permission.skill block (deny-all + allow only the agent's listed skills), and agent.yml passes SKILLS env to it.
  • README.md merged cleanly — it has both the new Skills column and the Repo layout section.

Verified: all 9 scripts pass bash -n; agent.yml is valid YAML (14 steps); the permission.skill block renders correctly per agent (junior=deny-all, pm/qa=gitea-api, senior/lead=gitea-api+node1-ssh); the two SKILL.md bodies are byte-identical to main; and the secrets-missing skip paths still exit 0.

PR #25 now contains both changes: ffaerber/agents#25

(Note: the now-redundant ai/issue-25-scripts branch still exists on the remote as its own PR — say the word and I'll note it should be closed in favor of #25, though I won't close/merge PRs myself per AGENTS.md.)

BEGIN_PR_DESCRIPTION

Summary

PR #25 now carries both the per-agent skill scoping and the inline-script externalization on branch ai/issue-22 (previously the externalization was on a separate branch, so only one commit showed here). Together: each agent only ever sees the skills relevant to its role, and the shared agent.yml shrinks from ~723 to ~190 lines with each step calling a dedicated script.

Changes

  • Skill scoping: skills allow-list in the agent registry (route.sh), emitted as a step output; install-opencode.sh writes a permission.skill block that denies all skills by default and allows only the listed ones (pm/qa: gitea-api; senior/lead: gitea-api+node1-ssh; junior: none).
  • Script externalization (pure refactor): move each step's shell into .gitea/workflows/scripts/ (route.sh, install-opencode.sh, skill-node1-ssh.sh, skill-gitea-api.sh, fetch-images.sh, fetch-thread.sh, run-agent.sh, build-activity-log.sh, publish.sh); each step is now bash "$SCRIPTS/<name>.sh". The two extracted SKILL.md bodies are byte-identical to main.
  • Add a checkout of this repo into .agents-workflow/ (pinned @main) so scripts exist at runtime in caller repos (reusable workflow_call).
  • README.md: add a Skills column, document per-agent skill scoping, and describe the new repo layout + reusable-workflow checkout requirement.

Resolves #22 · 🤖 @lead
END_PR_DESCRIPTION

🤖 **@lead** — activity log (142 entries): <details> <summary>tool calls & reasoning</summary> 💬 The maintainer is saying the script-externalization changes aren't on `ai/issue-22` — only 1 commit found. Let me investigate the branch state. 🔧 **bash**: `git branch --show-current && echo "--- local branches ---" && git branch -a && echo "--- fetch ---" && git fetch -q origin 2>&1 | tail -3` 💬 Now I understand. The two changes are on two different branches: - `ai/issue-22` → skill-scoping (1 commit) — this is PR #25 - `ai/issue-25-scripts` → script-externalization The maintainer expected BOTH on `ai/issue-22` (which is what PR #25 tracks). Let me verify. 🔧 **bash**: `echo "=== origin/ai/issue-22 log ===" && git log --oneline origin/ai/issue-22 -5 echo "" echo "=== origin/ai/issue-25-scripts log ===" && git log --oneline origin/ai/issue-25-scripts -5 echo "" echo "=== origin/main log ===" && git log --on…` 💬 The situation is clear. `ai/issue-22` (PR #25) has only the skill-scoping commit. The script-externalization is on a separate branch `ai/issue-25-scripts`. The maintainer is looking at PR #25 (`ai/issue-22`) and doesn't see the script changes there — they expected both changes on `ai/issue-22`. The maintainer wants the script-externalization changes ON `ai/issue-22`. Let me cherry-pick the script-externalization commit onto `ai/issue-22` so PR #25 contains both. Let me first check whether the two commits conflict (skill-scoping touches `route.sh`/`install-opencode.sh` content that script-externalization also creates). 🔧 **bash**: `echo "=== files changed by skill-scoping (issue-22) ===" && git diff --stat origin/main..origin/ai/issue-22 echo "" echo "=== files changed by script-externalization (issue-25-scripts) ===" && git diff --stat origin/main..origin/ai/issue-25…` 💬 Both touch `agent.yml` and `README.md` heavily, so a cherry-pick will conflict. The cleanest approach: combine both changes properly on `ai/issue-22` so PR #25 contains the skill-scoping AND the externalized scripts — with the scripts reflecting the skill-scoping behavior (since they'll live together on the same branch). The critical decision: when both live together, `route.sh` and `install-opencode.sh` must contain the skill-scoping logic (the `skills` field + `permission.skill` block), because otherwise externalizing would drop the skill-scoping behavior. Let me look at the current `ai/issue-22` agent.yml (skill-scoping applied) and the script-externalization scripts to reconcile them. 🔧 **bash**: `git checkout ai/issue-22 2>&1 | tail -2 echo "=== current agent.yml line count on issue-22 ===" wc -l .gitea/workflows/agent.yml` 🔧 **todowrite**: `5 todos` 💬 Let me approach this by cherry-picking the script-externalization commit onto `ai/issue-22` and resolving conflicts. This preserves the git history cleanly and lets me reconcile the two changes. 🔧 **bash**: `git cherry-pick 2c8f290 2>&1 | tail -20` 💬 README.md auto-merged. Let me look at the conflict in agent.yml. 🔧 **bash**: `git status --short && echo "--- scripts created? ---" && ls .gitea/workflows/scripts/ 2>/dev/null` 💬 The scripts were created. The conflict is only in `agent.yml`. The script-externalization version of `agent.yml` (the "theirs" side) is the slimmed-down version. I want to take the externalized `agent.yml` structure but ensure the scripts contain the skill-scoping logic. The strategy: take the script-externalized `agent.yml` (slim version), then patch `route.sh` + `install-opencode.sh` to add the skill-scoping behavior that was in the skill-scoping commit. Let me resolve the conflict by taking "theirs" (the externalized version). 🔧 **bash**: `git checkout --theirs .gitea/workflows/agent.yml && git add .gitea/workflows/agent.yml && echo "resolved agent.yml to externalized version" && wc -l .gitea/workflows/agent.yml` 💬 Now I need to check the README conflict resolution and the agent.yml — but critically I need to reconcile the skill-scoping into the externalized scripts. Let me first check the README merge result and the agent.yml current state to see what skill-scoping bits were lost. 🔧 **bash**: `echo "=== README conflict markers? ===" && grep -n '<<<<<<<\|>>>>>>>\|=======' README.md || echo "no conflicts in README" echo "" echo "=== agent.yml: does it reference skills/permission/OC_SKILLS? ===" grep -n 'skill\|permission\|OC_SKILLS…` 💬 So the externalized `agent.yml` lost the skill-scoping (the `skills` field in route registry, the `OC_SKILLS` output emission, and the `permission.skill` block in install-opencode). I need to re-add these to the externalized scripts. Let me examine what the skill-scoping commit actually changed in agent.yml, so I can port that logic into `route.sh` and `install-opencode.sh`. 🔧 **bash**: `git show a79b49b -- .gitea/workflows/agent.yml` 💬 Now I understand exactly what needs to be ported. Let me look at the current externalized `route.sh` and `install-opencode.sh` and add the skill-scoping logic. 🔧 **read**: `.gitea/workflows/scripts/route.sh` 💬 Now let me add the skill-scoping into `route.sh` (registry `skills` field + `skills` output emission). 🔧 **edit**: `.gitea/workflows/scripts/route.sh` 🔧 **edit**: `.gitea/workflows/scripts/route.sh` 💬 Now `install-opencode.sh`. Let me read it. 🔧 **read**: `.gitea/workflows/scripts/install-opencode.sh` 🔧 **edit**: `.gitea/workflows/scripts/install-opencode.sh` 🔧 **edit**: `.gitea/workflows/scripts/install-opencode.sh` 💬 Now the `install-opencode` step in `agent.yml` must pass `SKILLS` env. Let me check the current install-opencode step in the externalized agent.yml. 🔧 **read**: `.gitea/workflows/agent.yml` 🔧 **read**: `.gitea/workflows/agent.yml` 💬 Now let me find the install-opencode step (the one that calls install-opencode.sh). 🔧 **bash**: `grep -n 'install-opencode\|Install opencode\|OLLAMA_CLOUD_API_KEY\|SKILLS' .gitea/workflows/agent.yml` 🔧 **read**: `.gitea/workflows/agent.yml` 🔧 **edit**: `.gitea/workflows/agent.yml` 💬 Now let me check the README merge result to make sure both the skill-scoping doc and the repo-layout doc are present and coherent. 🔧 **bash**: `grep -n -i 'skill\|repo layout\|scripts/\|\.agents-workflow' README.md | head -40` 💬 The README auto-merge cleanly combined both. The Skills column (from skill-scoping) and the Repo layout section (from script-externalization) are both present. Let me verify the README table has the Skills column populated correctly. 🔧 **read**: `README.md` 💬 README is fully reconciled. Now let me complete the cherry-pick and validate everything. 🔧 **bash**: `git add -A && git status --short` 🔧 **todowrite**: `2 todos` 💬 Now let me validate: bash syntax on all scripts, YAML parse, and test the skill-scoping logic in route.sh + install-opencode.sh. 🔧 **bash**: `echo "=== bash -n on all scripts ===" for f in .gitea/workflows/scripts/*.sh; do bash -n "$f" && echo "OK: $f" || echo "FAIL: $f"; done echo "" echo "=== YAML parse ===" python3 -c "import yaml; d=yaml.safe_load(open('.gitea/workflows/agen…` 💬 All scripts pass syntax, YAML is valid with 14 steps. Now let me test the skill-scoping logic produces the correct `permission.skill` block for each agent, and that the combined config matches the standalone skill-scoping PR's output. 🔧 **bash**: `# Test route.sh registry + skills extraction for each agent cat > /tmp/agents.json <<'JSON' { "pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"PM"}, "junior": {"model":"ollama…` 💬 The skill-scoping logic produces correct permission blocks for all agents. Now let me verify the full install-opencode.sh generates a valid opencode.json end-to-end (simulate for lead). 🔧 **bash**: `export HOME=/tmp/oc-test; rm -rf "$HOME"; mkdir -p "$HOME" export GITHUB_PATH=/tmp/ghpath; : > "$GITHUB_PATH" export OLLAMA_URL="http://ornith:11434" export OLLAMA_CLOUD_API_KEY="CKEY" export NAME="lead" export SKILLS='["gitea-api","node1-s…` 💬 The combined config is valid and includes the `permission.skill` block. Now let me verify the extracted skill script bodies are still byte-identical to main (the pure-refactor guarantee), then complete the cherry-pick. 🔧 **bash**: `# Verify node1-ssh + gitea-api SKILL.md bodies are byte-identical to main git show origin/main:.gitea/workflows/agent.yml > /tmp/main_agent.yml export HOME=/tmp/sk1; rm -rf "$HOME"; mkdir -p "$HOME" SWARM_HOST="h" SWARM_USER="u" SSH_PRIV_K…` 💬 All scripts work correctly and skip gracefully. Now let me complete the cherry-pick with a clear commit message describing the reconciliation. 🔧 **bash**: `GIT_EDITOR=true git cherry-pick --continue 2>&1 | tail -5 echo "--- amend message to reflect reconciliation ---" git commit --amend -q -m "@lead: externalize agent.yml inline scripts into .gitea/workflows/scripts/*.sh Rebased onto the per-…` 💬 Now `ai/issue-22` has BOTH commits. Let me do a final verification of the full agent.yml, then push. 🔧 **bash**: `echo "=== all script references exist? ===" grep -oE '\$SCRIPTS/[a-z-]+\.sh' .gitea/workflows/agent.yml | sort -u | while read s; do f=".gitea/workflows/scripts/${s#\$SCRIPTS/}" [ -f "$f" ] && echo "OK: $s" || echo "MISSING: $s" done ec…` 💬 Everything is in place. Both changes (skill-scoping + script-externalization) are now on `ai/issue-22` as two commits. Let me push. 🔧 **bash**: `git push origin HEAD:ai/issue-22 2>&1 | tail -8 echo "--- verify remote now has 2 commits ---" git fetch -q origin && git log --oneline origin/ai/issue-22 -4` 💬 Pushed. `ai/issue-22` (PR #25) now has **both** commits. 🔧 **todowrite**: `0 todos` 💬 Fixed. You were right — the script-externalization was sitting on a **separate** branch (`ai/issue-25-scripts`), so PR #25 (which tracks `ai/issue-22`) only showed the one skill-scoping commit. I'd split them into two independent PRs; you wanted both in PR #25. **What I did:** cherry-picked the script-externalization commit onto `ai/issue-22` and reconciled the overlap so nothing was lost. `ai/issue-22` now has **2 commits**: 1. `a79b49b` — per-agent skill scoping (`permission.skill`) 2. `36ba91c` — externalize inline scripts into `.gitea/workflows/scripts/*.sh` **The one thing that needed care:** both changes edit `agent.yml`, so when the scripts got externalized I had to make sure the skill-scoping logic moved *into* the scripts rather than being dropped: - `route.sh` keeps the registry `skills` allow-list and emits `skills` as a step output. - `install-opencode.sh` writes the `permission.skill` block (deny-all + allow only the agent's listed skills), and `agent.yml` passes `SKILLS` env to it. - `README.md` merged cleanly — it has both the new **Skills** column and the **Repo layout** section. **Verified:** all 9 scripts pass `bash -n`; `agent.yml` is valid YAML (14 steps); the `permission.skill` block renders correctly per agent (junior=deny-all, pm/qa=`gitea-api`, senior/lead=`gitea-api`+`node1-ssh`); the two `SKILL.md` bodies are byte-identical to `main`; and the secrets-missing skip paths still exit 0. PR #25 now contains both changes: https://git.ffaerber.duckdns.org/ffaerber/agents/pulls/25 (Note: the now-redundant `ai/issue-25-scripts` branch still exists on the remote as its own PR — say the word and I'll note it should be closed in favor of #25, though I won't close/merge PRs myself per AGENTS.md.) BEGIN_PR_DESCRIPTION ## Summary PR #25 now carries **both** the per-agent skill scoping and the inline-script externalization on branch `ai/issue-22` (previously the externalization was on a separate branch, so only one commit showed here). Together: each agent only ever sees the skills relevant to its role, and the shared `agent.yml` shrinks from ~723 to ~190 lines with each step calling a dedicated script. ## Changes - **Skill scoping:** `skills` allow-list in the agent registry (`route.sh`), emitted as a step output; `install-opencode.sh` writes a `permission.skill` block that denies all skills by default and allows only the listed ones (pm/qa: `gitea-api`; senior/lead: `gitea-api`+`node1-ssh`; junior: none). - **Script externalization (pure refactor):** move each step's shell into `.gitea/workflows/scripts/` (`route.sh`, `install-opencode.sh`, `skill-node1-ssh.sh`, `skill-gitea-api.sh`, `fetch-images.sh`, `fetch-thread.sh`, `run-agent.sh`, `build-activity-log.sh`, `publish.sh`); each step is now `bash "$SCRIPTS/<name>.sh"`. The two extracted `SKILL.md` bodies are byte-identical to `main`. - Add a checkout of this repo into `.agents-workflow/` (pinned `@main`) so scripts exist at runtime in caller repos (reusable `workflow_call`). - `README.md`: add a Skills column, document per-agent skill scoping, and describe the new repo layout + reusable-workflow checkout requirement. --- Resolves #22 · 🤖 @lead END_PR_DESCRIPTION </details>
Owner

@lead issue-25 never existed. how can this happen? how can we prevent this mistake in the feature?

@lead issue-25 never existed. how can this happen? how can we prevent this mistake in the feature?

🤖 @lead — updated branch/PR:

The maintainer is asking a process/retrospective question, not requesting a code change. Let me investigate the actual git state to answer accurately before saying anything.
The maintainer is right, and this is a process question. Let me answer it honestly. ai/issue-22 (PR #25) now correctly has both commits. The stray ai/issue-25-scripts branch is the mistake. This is a retrospective — no code change requested, so I'll make none and explain the root cause and prevention.

The core error: I invented a branch name (ai/issue-25-scripts) that encoded a non-existent issue number. The automation opens one PR per pushed branch, and I misread "PR #25" (a pull-request number) as if it were an issue number, then baked "25" into a branch name for a second, unrelated change. There was never an issue #25 — the actual issue is #22 ("Resolves #22"), and #25 is just the PR that tracks branch ai/issue-22.

Root cause (three compounding mistakes):

  1. Conflated PR number with issue number. I saw the PR URL .../pulls/25 and wrongly treated 25 as an issue. The AGENTS.md rule is "Start on ai/issue-<N>" where <N> is the issue number — always #22 here. I should never have derived a branch name from a PR number.

  2. Split one issue's work across two branches. AGENTS.md says to split independent changes into separate branches. But the maintainer wanted both changes on the same issue #22 review. I over-applied the "one PR each" rule to work that belonged together, creating a second branch that then needed a confusing cherry-pick to reunite.

  3. Left the stray branch/PR orphaned. After reconciling both commits onto ai/issue-22, I never got ai/issue-25-scripts cleaned up (I can't close PRs per AGENTS.md), so a phantom "issue-25" PR still lingers and caused this confusion.

How to prevent it in the future (concrete, enforceable):

  1. Branch names come from the issue number in the task header only — never from a PR URL. The task always states the issue number ("issue #22"). Rule: ai/issue-<N> where <N> is that number, full stop. Never parse /pulls/<n> for a branch name.

  2. All work for one issue stays on that issue's branch. Split into separate branches only when changes target different issues. Multiple commits on ai/issue-<N> is the right way to deliver multiple related changes for one issue — not multiple branches.

  3. This could be enforced in the workflow itself. Since this repo is the shared agent workflow, we could add a guard in route.sh/publish.sh that (a) validates the branch matches ai/issue-<N> where <N> equals the triggering issue number, and (b) refuses to push a branch whose number doesn't match the issue in context. That turns a guideline into a hard gate.

Current state is correct: PR #25 / branch ai/issue-22 has both commits (a79b49b skill-scoping, 36ba91c script externalization). The only cleanup needed is closing the phantom PR from ai/issue-25-scripts — I can't do that myself per AGENTS.md, so please close it in favor of #25.

Want me to add the branch-name validation guard to the workflow as a follow-up (on ai/issue-22, since it's part of hardening this same shared workflow)? I've made no code changes for this reply since your message was a process question.

🤖 **@lead** — updated branch/PR: - https://git.ffaerber.duckdns.org/ffaerber/agents/pulls/25 The maintainer is asking a process/retrospective question, not requesting a code change. Let me investigate the actual git state to answer accurately before saying anything. The maintainer is right, and this is a process question. Let me answer it honestly. `ai/issue-22` (PR #25) now correctly has both commits. The stray `ai/issue-25-scripts` branch is the mistake. This is a retrospective — no code change requested, so I'll make none and explain the root cause and prevention. The core error: **I invented a branch name (`ai/issue-25-scripts`) that encoded a non-existent issue number.** The automation opens one PR per pushed branch, and I misread "PR #25" (a *pull-request* number) as if it were an *issue* number, then baked "25" into a branch name for a second, unrelated change. There was never an issue #25 — the actual issue is #22 ("Resolves #22"), and #25 is just the PR that tracks branch `ai/issue-22`. **Root cause (three compounding mistakes):** 1. **Conflated PR number with issue number.** I saw the PR URL `.../pulls/25` and wrongly treated `25` as an issue. The AGENTS.md rule is "Start on `ai/issue-<N>`" where `<N>` is the *issue* number — always #22 here. I should never have derived a branch name from a PR number. 2. **Split one issue's work across two branches.** AGENTS.md says to split *independent* changes into separate branches. But the maintainer wanted both changes on the same issue #22 review. I over-applied the "one PR each" rule to work that belonged together, creating a second branch that then needed a confusing cherry-pick to reunite. 3. **Left the stray branch/PR orphaned.** After reconciling both commits onto `ai/issue-22`, I never got `ai/issue-25-scripts` cleaned up (I can't close PRs per AGENTS.md), so a phantom "issue-25" PR still lingers and caused this confusion. **How to prevent it in the future** (concrete, enforceable): 1. **Branch names come from the issue number in the task header only — never from a PR URL.** The task always states the issue number ("issue #22"). Rule: `ai/issue-<N>` where `<N>` is that number, full stop. Never parse `/pulls/<n>` for a branch name. 2. **All work for one issue stays on that issue's branch.** Split into separate branches only when changes target *different issues*. Multiple commits on `ai/issue-<N>` is the right way to deliver multiple related changes for one issue — not multiple branches. 3. **This could be enforced in the workflow itself.** Since this repo *is* the shared agent workflow, we could add a guard in `route.sh`/`publish.sh` that (a) validates the branch matches `ai/issue-<N>` where `<N>` equals the triggering issue number, and (b) refuses to push a branch whose number doesn't match the issue in context. That turns a guideline into a hard gate. **Current state is correct:** PR #25 / branch `ai/issue-22` has both commits (`a79b49b` skill-scoping, `36ba91c` script externalization). The only cleanup needed is closing the phantom PR from `ai/issue-25-scripts` — I can't do that myself per AGENTS.md, so please close it in favor of #25. Want me to add the branch-name validation guard to the workflow as a follow-up (on `ai/issue-22`, since it's part of hardening this same shared workflow)? I've made no code changes for this reply since your message was a process question.

🤖 @lead — activity log (28 entries):

tool calls & reasoning

💬 The maintainer is asking a process/retrospective question, not requesting a code change. Let me investigate the actual git state to answer accurately before saying anything.
🔧 bash: git branch --show-current && echo "--- local branches ---" && git branch -a && echo "--- fetch ---" && git fetch -q origin 2>&1 | tail -3
🔧 bash: echo "=== origin/ai/issue-22 log ===" && git log --oneline origin/ai/issue-22 -5 echo "" echo "=== origin/ai/issue-25-scripts log ===" && git log --oneline origin/ai/issue-25-scripts -5
💬 The maintainer is right, and this is a process question. Let me answer it honestly. ai/issue-22 (PR #25) now correctly has both commits. The stray ai/issue-25-scripts branch is the mistake. This is a retrospective — no code change requested, so I'll make none and explain the root cause and prevention.

The core error: I invented a branch name (ai/issue-25-scripts) that encoded a non-existent issue number. The automation opens one PR per pushed branch, and I misread "PR #25" (a pull-request number) as if it were an issue number, then baked "25" into a branch name for a second, unrelated change. There was never an issue #25 — the actual issue is #22 ("Resolves #22"), and #25 is just the PR that tracks branch ai/issue-22.

Root cause (three compounding mistakes):

  1. Conflated PR number with issue number. I saw the PR URL .../pulls/25 and wrongly treated 25 as an issue. The AGENTS.md rule is "Start on ai/issue-<N>" where <N> is the issue number — always #22 here. I should never have derived a branch name from a PR number.

  2. Split one issue's work across two branches. AGENTS.md says to split independent changes into separate branches. But the maintainer wanted both changes on the same issue #22 review. I over-applied the "one PR each" rule to work that belonged together, creating a second branch that then needed a confusing cherry-pick to reunite.

  3. Left the stray branch/PR orphaned. After reconciling both commits onto ai/issue-22, I never got ai/issue-25-scripts cleaned up (I can't close PRs per AGENTS.md), so a phantom "issue-25" PR still lingers and caused this confusion.

How to prevent it in the future (concrete, enforceable):

  1. Branch names come from the issue number in the task header only — never from a PR URL. The task always states the issue number ("issue #22"). Rule: ai/issue-<N> where <N> is that number, full stop. Never parse /pulls/<n> for a branch name.

  2. All work for one issue stays on that issue's branch. Split into separate branches only when changes target different issues. Multiple commits on ai/issue-<N> is the right way to deliver multiple related changes for one issue — not multiple branches.

  3. This could be enforced in the workflow itself. Since this repo is the shared agent workflow, we could add a guard in route.sh/publish.sh that (a) validates the branch matches ai/issue-<N> where <N> equals the triggering issue number, and (b) refuses to push a branch whose number doesn't match the issue in context. That turns a guideline into a hard gate.

Current state is correct: PR #25 / branch ai/issue-22 has both commits (a79b49b skill-scoping, 36ba91c script externalization). The only cleanup needed is closing the phantom PR from ai/issue-25-scripts — I can't do that myself per AGENTS.md, so please close it in favor of #25.

Want me to add the branch-name validation guard to the workflow as a follow-up (on ai/issue-22, since it's part of hardening this same shared workflow)? I've made no code changes for this reply since your message was a process question.

🤖 **@lead** — activity log (28 entries): <details> <summary>tool calls & reasoning</summary> 💬 The maintainer is asking a process/retrospective question, not requesting a code change. Let me investigate the actual git state to answer accurately before saying anything. 🔧 **bash**: `git branch --show-current && echo "--- local branches ---" && git branch -a && echo "--- fetch ---" && git fetch -q origin 2>&1 | tail -3` 🔧 **bash**: `echo "=== origin/ai/issue-22 log ===" && git log --oneline origin/ai/issue-22 -5 echo "" echo "=== origin/ai/issue-25-scripts log ===" && git log --oneline origin/ai/issue-25-scripts -5` 💬 The maintainer is right, and this is a process question. Let me answer it honestly. `ai/issue-22` (PR #25) now correctly has both commits. The stray `ai/issue-25-scripts` branch is the mistake. This is a retrospective — no code change requested, so I'll make none and explain the root cause and prevention. The core error: **I invented a branch name (`ai/issue-25-scripts`) that encoded a non-existent issue number.** The automation opens one PR per pushed branch, and I misread "PR #25" (a *pull-request* number) as if it were an *issue* number, then baked "25" into a branch name for a second, unrelated change. There was never an issue #25 — the actual issue is #22 ("Resolves #22"), and #25 is just the PR that tracks branch `ai/issue-22`. **Root cause (three compounding mistakes):** 1. **Conflated PR number with issue number.** I saw the PR URL `.../pulls/25` and wrongly treated `25` as an issue. The AGENTS.md rule is "Start on `ai/issue-<N>`" where `<N>` is the *issue* number — always #22 here. I should never have derived a branch name from a PR number. 2. **Split one issue's work across two branches.** AGENTS.md says to split *independent* changes into separate branches. But the maintainer wanted both changes on the same issue #22 review. I over-applied the "one PR each" rule to work that belonged together, creating a second branch that then needed a confusing cherry-pick to reunite. 3. **Left the stray branch/PR orphaned.** After reconciling both commits onto `ai/issue-22`, I never got `ai/issue-25-scripts` cleaned up (I can't close PRs per AGENTS.md), so a phantom "issue-25" PR still lingers and caused this confusion. **How to prevent it in the future** (concrete, enforceable): 1. **Branch names come from the issue number in the task header only — never from a PR URL.** The task always states the issue number ("issue #22"). Rule: `ai/issue-<N>` where `<N>` is that number, full stop. Never parse `/pulls/<n>` for a branch name. 2. **All work for one issue stays on that issue's branch.** Split into separate branches only when changes target *different issues*. Multiple commits on `ai/issue-<N>` is the right way to deliver multiple related changes for one issue — not multiple branches. 3. **This could be enforced in the workflow itself.** Since this repo *is* the shared agent workflow, we could add a guard in `route.sh`/`publish.sh` that (a) validates the branch matches `ai/issue-<N>` where `<N>` equals the triggering issue number, and (b) refuses to push a branch whose number doesn't match the issue in context. That turns a guideline into a hard gate. **Current state is correct:** PR #25 / branch `ai/issue-22` has both commits (`a79b49b` skill-scoping, `36ba91c` script externalization). The only cleanup needed is closing the phantom PR from `ai/issue-25-scripts` — I can't do that myself per AGENTS.md, so please close it in favor of #25. Want me to add the branch-name validation guard to the workflow as a follow-up (on `ai/issue-22`, since it's part of hardening this same shared workflow)? I've made no code changes for this reply since your message was a process question. </details>
Owner

@lead can you create a issue so we can fix this in a new PR

@lead can you create a issue so we can fix this in a new PR

🤖 @lead — updated branch/PR:

I'll create an issue for the branch-naming validation guard. Let me first check the gitea-api skill to use the API correctly.
Now let me verify the environment variables and repo context.
Good. Let me create the issue via the API.
Done. Created issue #26.

#26 — Enforce branch naming (ai/issue-<N>) from the triggering issue number

It captures the root cause (PR number 25 mistaken for an issue number, and one issue's work split across two branches) and proposes the fix as a hard gate in the shared workflow:

  1. Derive the branch name solely from the triggering issue number in route.sh — never from a PR URL.
  2. Validate before push (in route.sh/publish.sh): fail the step if the branch isn't ai/issue-<N> matching the triggering issue.
  3. Keep all of one issue's work on that issue's branch (stacked commits, not multiple branches).
  4. Document the rule in AGENTS.md + workflow README.

It also notes the leftover cleanup (close the phantom ai/issue-25-scripts PR in favor of #25).

I made no code changes — this reply only opened the issue as you asked. When you want the fix implemented, I'll do it on ai/issue-26.

I did not include a BEGIN_PR_DESCRIPTION block since this task created an issue rather than pushing a branch for a PR.

🤖 **@lead** — updated branch/PR: - https://git.ffaerber.duckdns.org/ffaerber/agents/pulls/25 I'll create an issue for the branch-naming validation guard. Let me first check the gitea-api skill to use the API correctly. Now let me verify the environment variables and repo context. Good. Let me create the issue via the API. Done. Created issue #26. **[#26 — Enforce branch naming (`ai/issue-<N>`) from the triggering issue number](https://git.ffaerber.duckdns.org/ffaerber/agents/issues/26)** It captures the root cause (PR number `25` mistaken for an issue number, and one issue's work split across two branches) and proposes the fix as a **hard gate** in the shared workflow: 1. Derive the branch name solely from the triggering issue number in `route.sh` — never from a PR URL. 2. Validate before push (in `route.sh`/`publish.sh`): fail the step if the branch isn't `ai/issue-<N>` matching the triggering issue. 3. Keep all of one issue's work on that issue's branch (stacked commits, not multiple branches). 4. Document the rule in `AGENTS.md` + workflow README. It also notes the leftover cleanup (close the phantom `ai/issue-25-scripts` PR in favor of #25). I made no code changes — this reply only opened the issue as you asked. When you want the fix implemented, I'll do it on `ai/issue-26`. I did not include a `BEGIN_PR_DESCRIPTION` block since this task created an issue rather than pushing a branch for a PR.

🤖 @lead — activity log (27 entries):

tool calls & reasoning

💬 I'll create an issue for the branch-naming validation guard. Let me first check the gitea-api skill to use the API correctly.
🔧 skill: Loaded skill: gitea-api
💬 Now let me verify the environment variables and repo context.
🔧 bash: echo "SERVER=${GITHUB_SERVER_URL}" && echo "REPO=${GITHUB_REPOSITORY}" && echo "TOKEN_SET=$([ -n "$AGENT_TOKEN" ] && echo yes || echo no)"
💬 Good. Let me create the issue via the API.
🔧 bash: API="${GITHUB_SERVER_URL}/api/v1" curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ "$API/repos/${GITHUB_REPOSITORY}/issues" \ -d @- <<'JSON' | jq '{number, title, html_url, state}' { "title…
💬 Done. Created issue #26.

#26 — Enforce branch naming (ai/issue-<N>) from the triggering issue number

It captures the root cause (PR number 25 mistaken for an issue number, and one issue's work split across two branches) and proposes the fix as a hard gate in the shared workflow:

  1. Derive the branch name solely from the triggering issue number in route.sh — never from a PR URL.
  2. Validate before push (in route.sh/publish.sh): fail the step if the branch isn't ai/issue-<N> matching the triggering issue.
  3. Keep all of one issue's work on that issue's branch (stacked commits, not multiple branches).
  4. Document the rule in AGENTS.md + workflow README.

It also notes the leftover cleanup (close the phantom ai/issue-25-scripts PR in favor of #25).

I made no code changes — this reply only opened the issue as you asked. When you want the fix implemented, I'll do it on ai/issue-26.

I did not include a BEGIN_PR_DESCRIPTION block since this task created an issue rather than pushing a branch for a PR.

🤖 **@lead** — activity log (27 entries): <details> <summary>tool calls & reasoning</summary> 💬 I'll create an issue for the branch-naming validation guard. Let me first check the gitea-api skill to use the API correctly. 🔧 **skill**: `Loaded skill: gitea-api` 💬 Now let me verify the environment variables and repo context. 🔧 **bash**: `echo "SERVER=${GITHUB_SERVER_URL}" && echo "REPO=${GITHUB_REPOSITORY}" && echo "TOKEN_SET=$([ -n "$AGENT_TOKEN" ] && echo yes || echo no)"` 💬 Good. Let me create the issue via the API. 🔧 **bash**: `API="${GITHUB_SERVER_URL}/api/v1" curl -sS -X POST -H "Authorization: token $AGENT_TOKEN" -H "Content-Type: application/json" \ "$API/repos/${GITHUB_REPOSITORY}/issues" \ -d @- <<'JSON' | jq '{number, title, html_url, state}' { "title…` 💬 Done. Created issue #26. **[#26 — Enforce branch naming (`ai/issue-<N>`) from the triggering issue number](https://git.ffaerber.duckdns.org/ffaerber/agents/issues/26)** It captures the root cause (PR number `25` mistaken for an issue number, and one issue's work split across two branches) and proposes the fix as a **hard gate** in the shared workflow: 1. Derive the branch name solely from the triggering issue number in `route.sh` — never from a PR URL. 2. Validate before push (in `route.sh`/`publish.sh`): fail the step if the branch isn't `ai/issue-<N>` matching the triggering issue. 3. Keep all of one issue's work on that issue's branch (stacked commits, not multiple branches). 4. Document the rule in `AGENTS.md` + workflow README. It also notes the leftover cleanup (close the phantom `ai/issue-25-scripts` PR in favor of #25). I made no code changes — this reply only opened the issue as you asked. When you want the fix implemented, I'll do it on `ai/issue-26`. I did not include a `BEGIN_PR_DESCRIPTION` block since this task created an issue rather than pushing a branch for a PR. </details>
ffaerber merged commit d8d4ba71c0 into main 2026-07-04 08:27:13 +02:00
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: gitea/agents#25