Skip to content

Commit cf306c9

Browse files
szjanikowskiSzymon Janikowskiclaude
authored
fix(skills): route [[skill]] + plugin skills natively for Codex/Gemini (ADR-012) (#67)
* fix(skills): route [[skill]] + plugin skills natively for Codex/Gemini (ADR-012) PR #65 fixed only the SNAPSHOT path (agents_skills/, gemini_skills/). The two other ways a skill reaches an agent — [[skill]] by-reference in variant.toml and a [nasde.plugin]'s own skills/ — still went through stage_skill_dir's hardcoded /app/.claude/skills/ target. Correct for Claude (cwd discovery root), but for a Codex/Gemini variant the skill landed where the CLI never scans, so it was never natively registered — the same silent "skill-as-document" failure, just via a different path. Uncaught today (no non-Claude variant used [[skill]]/plugin) but a latent trap for the core skill-migration use case. Both paths now resolve to skill DIRECTORIES (plugin_registration.collect_ referenced_skill_dirs / collect_plugin_skill_dirs) and, for Codex/Gemini, route through Harbor's native config.agent.skills — unioned with the snapshot dirs inside _refresh_agent_skills under one _nasde_derived_skills marker (stale-drop and hand-authored preservation work across both sources). Claude keeps its unchanged sandbox_files path; a Claude agent drops extra_skill_dirs at merge to avoid double-injecting. The frontmatter warning now fires on this path too, and a new warning flags two derived dirs sharing a basename (Harbor resolve_skills is last-wins, so the loser would silently vanish). New example variants codex-nasde-dev-with-arch and gemini-nasde-dev-with-arch exercise [[skill]] by-reference on a non-Claude agent (and add the long-missing Codex variant to nasde-dev-skill). Config verified: the codex variant's generated harbor_config carries skills=[nasde-dev] (native) with the [[skill]] dir, not just the Claude sandbox entry. Full suite 393 passing; ruff/format/mypy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(skills): normalize Rich-wrapped warning output (Windows CI) The frontmatter and basename-collision warning assertions matched a literal substring against capsys output, but Rich wraps the long tmp-path line on Windows' narrow CI console, splitting 'missing YAML frontmatter' across a newline. Normalize with ' '.join(out.split()) like the existing collision tests. Verified with COLUMNS=80 locally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(skills): resolve [[skill]]/plugin dirs so dedup matches snapshot (review) Code-review finding: `_collect_native_skill_dirs` returns `str(dir.resolve())` (resolved), but the new native channel did NOT resolve — `collect_plugin_skill_ dirs` returned `staged.staged_dir / name` and `_resolve_skill_source`'s ref branch returned `worktree / relative`, both unresolved. In `_refresh_agent_ skills` the union/dedup, `_nasde_derived_skills` stale-drop, handwritten preservation and basename-collision warning all key on string equality, so the same logical dir in two string forms (a symlinked /tmp on macOS, or a ref worktree) would fail to dedup, break stale-drop across runs, and fire a spurious collision warning. Both collect_* now `.resolve()` their dirs to match the snapshot path. Adds canonical-path regression tests. Also corrected a misleading comment claiming create_ref_worktree caches/dedups ref worktrees — it does not; the lifecycle is safe because all worktrees live in _active_worktrees until cleanup_worktrees() in the finally after the job. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(skills): use dict.fromkeys for order-preserving dedup (review) Replace the hand-rolled _dedup_preserving_order helper with the stdlib idiom list(dict.fromkeys(...)) — same behavior, one line, more discoverable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(skills): integration coverage for [nasde.plugin] skills routing The [[skill]] path is verified e2e on a live agent, but there is no example with a [nasde.plugin], so the plugin path had only unit coverage on a synthetic StagedPlugin. Add two integration tests that drive the REAL ensure_task_plugin staging through _prepare_task_environments → collect_plugin_skill_dirs → harbor_config: a plugin's own skills/ lands in native config.agent.skills (resolved) for a Codex variant, and in /app/.claude/skills sandbox_files for a Claude variant. Closes the 'does collect_plugin_skill_dirs read the right staged location' gap without Docker. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(adr-012): note plugin path is integration-tested, not e2e (honest coverage) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(skills): OS-agnostic basename assert in plugin test (Windows CI) assert s.endswith('/plug-skill') hardcoded a forward slash and failed on Windows where the resolved host path uses backslashes. Use Path(s).name == 'plug-skill' instead. (The /app/.claude/... sandbox assertion is a container posix path and stays as-is.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Szymon Janikowski <szymon.janikowski@itlibrium.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 76016bd commit cf306c9

12 files changed

Lines changed: 585 additions & 47 deletions

File tree

.claude/skills/nasde-benchmark-creator/SKILL.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -336,10 +336,14 @@ variants/gemini-baseline/
336336

337337
> **Codex/Gemini skills are registered natively** (Harbor `config.agent.skills`),
338338
> not via `sandbox_files` — these CLIs auto-discover skills only from a
339-
> HOME-scoped dir, never from a `/app` cwd dir. Each `SKILL.md` **must start with
340-
> a `---` YAML frontmatter line**: Codex's loader rejects a file that opens with
341-
> anything else (`missing YAML frontmatter delimited by ---`) and silently skips
342-
> the skill. Put any provenance comment *below* the closing `---`. See
339+
> HOME-scoped dir, never from a `/app` cwd dir. This applies to **all** ways a
340+
> skill is supplied to a Codex/Gemini variant: the `agents_skills/` /
341+
> `gemini_skills/` snapshot above, a `[[skill]]` by-reference entry in
342+
> `variant.toml`, and a `[nasde.plugin]`'s own `skills/`. Each `SKILL.md`
343+
> **must start with a `---` YAML frontmatter line**: Codex's loader rejects a
344+
> file that opens with anything else (`missing YAML frontmatter delimited by
345+
> ---`) and silently skips the skill. Put any provenance comment *below* the
346+
> closing `---`. See
343347
> [ADR-012](../../../docs/adr/012-native-codex-gemini-skill-injection.md).
344348
345349
If no `harbor_config.json` exists, `nasde` auto-generates one from `variant.toml`. To customize (e.g., add MCP servers), create it explicitly:

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,23 @@ See [docs/RELEASING.md](docs/RELEASING.md) for the release procedure.
104104
max_turns` in `nasde.toml`. ([#54])
105105

106106
### Fixed
107+
- **`[[skill]]` by-reference and `[nasde.plugin]` skills now register natively for Codex/Gemini ([ADR-012](docs/adr/012-native-codex-gemini-skill-injection.md)).**
108+
PR #65 fixed the *snapshot* path (`agents_skills/`, `gemini_skills/`) but left
109+
the two other ways a skill reaches an agent — `[[skill]]` by-reference in
110+
`variant.toml` and a `[nasde.plugin]`'s own `skills/` — still hardcoding
111+
`/app/.claude/skills/` via `stage_skill_dir`. Correct for Claude (cwd discovery
112+
root), but for a **Codex/Gemini** variant the skill landed where the CLI never
113+
scans, so it was never natively registered (the same silent "skill-as-document"
114+
failure). Both paths now resolve to skill *directories*
115+
(`collect_referenced_skill_dirs` / `collect_plugin_skill_dirs`) routed through
116+
Harbor's native `config.agent.skills` for Codex/Gemini, unioned with the
117+
snapshot dirs under one `_nasde_derived_skills` marker (stale-drop +
118+
hand-authored preservation work across both sources); Claude keeps its
119+
unchanged `sandbox_files` path. The frontmatter warning now also fires on this
120+
path, and a new warning flags two derived skill dirs sharing a basename (Harbor
121+
registers only the last). New example variants `codex-nasde-dev-with-arch` and
122+
`gemini-nasde-dev-with-arch` exercise `[[skill]]` by-reference on a non-Claude
123+
agent. ([#67])
107124
- **Codex/Gemini skills are now natively registered ([ADR-012](docs/adr/012-native-codex-gemini-skill-injection.md)).**
108125
Codex and Gemini auto-discover skills only from a HOME-scoped directory
109126
(`$HOME/.agents/skills`, `~/.gemini/skills`), never from a `.agents/skills` /
@@ -522,4 +539,5 @@ Initial release under the **nasde-toolkit** name (rebrand from
522539
[#59]: https://github.com/NoesisVision/nasde-toolkit/pull/59
523540
[#61]: https://github.com/NoesisVision/nasde-toolkit/pull/61
524541
[#65]: https://github.com/NoesisVision/nasde-toolkit/pull/65
542+
[#67]: https://github.com/NoesisVision/nasde-toolkit/pull/67
525543
[gh-litellm-2026-04]: https://github.com/BerriAI/litellm/security/advisories/GHSA-xqmj-j6mv-4862

CLAUDE.md

Lines changed: 14 additions & 10 deletions
Large diffs are not rendered by default.

docs/adr/012-native-codex-gemini-skill-injection.md

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,36 @@ skills and `[[skill]]` by-reference (ADR-009) via `stage_skill_dir`. Migrating
9494
it onto `config.agent.skills` would be a larger, riskier change with no
9595
behavioral payoff. Scope is kept to the actual bug.
9696

97+
### All three skill paths, not just the snapshot
98+
99+
A skill reaches an agent three ways: the variant **snapshot**
100+
(`agents_skills/`, `gemini_skills/`), **`[[skill]]` by-reference** in
101+
`variant.toml`, and a **`[nasde.plugin]`**'s own `skills/`. The first revision
102+
of this decision fixed only the snapshot path; the latter two still went
103+
through `stage_skill_dir`'s hardcoded `/app/.claude/skills/` and so were broken
104+
for Codex/Gemini in exactly the same way.
105+
106+
All three are now routed natively for Codex/Gemini. `[[skill]]`/plugin skills
107+
are resolved to skill *directories* by `collect_referenced_skill_dirs` /
108+
`collect_plugin_skill_dirs` (the dir-list counterparts of the unchanged
109+
Claude-only `stage_referenced_skills` / `register_plugin_skills`), threaded as
110+
`extra_skill_dirs` and **unioned** with the snapshot dirs inside
111+
`_refresh_agent_skills`. The union is tracked by the single
112+
`_nasde_derived_skills` marker, so stale-drop and hand-authored preservation
113+
work across both sources. For a Claude agent `extra_skill_dirs` is dropped at
114+
merge time (those skills already ride `sandbox_files`, so feeding the native
115+
list too would double-inject). Because Harbor's `resolve_skills` keys skills by
116+
basename (last-wins), nasde warns when two derived dirs share a basename so the
117+
loser does not silently vanish.
118+
119+
**Coverage note (honest).** The `[[skill]]` path is verified end-to-end on a
120+
live agent (see Consequences). The `[nasde.plugin]` path shares the same channel
121+
from `extra_skill_dirs` onward, but there is no example benchmark with a
122+
`[nasde.plugin]` to run it e2e — so it is covered by an **integration test** that
123+
drives the real `docker.ensure_task_plugin` staging → `collect_plugin_skill_dirs`
124+
→ harbor_config (no Docker build), plus unit tests on the resolver. Full
125+
live-agent e2e for a plugin is a follow-up for when a plugin benchmark exists.
126+
97127
### Frontmatter guardrail
98128

99129
Codex's loader is strict: a `SKILL.md` that does not **start** with a `---`
@@ -123,18 +153,25 @@ comments moved below the closing `---`.
123153
its native mechanism, not by reading the file.
124154
- Skill×model matrix results for Codex/Gemini collected under the old behavior
125155
are invalid (skill-as-document, not skill-as-native) and must be re-run.
126-
- `sandbox_files` no longer carries skill files for Codex/Gemini; it carries
127-
only `AGENTS.md` / `GEMINI.md` (and, for Claude, the unchanged skill files).
156+
- For Codex/Gemini, skills (snapshot, `[[skill]]`, and plugin) are all carried
157+
by `config.agent.skills`; the agent's effective `sandbox_files` is just
158+
`AGENTS.md` / `GEMINI.md`. The Claude `sandbox_files` skill map is still built
159+
unconditionally (so Claude stays byte-identical and tested) but a Codex/Gemini
160+
agent never consumes it.
128161
- The brittle `_expand_skill_targets` workaround in `ConfigurableGemini` (which
129162
hardcoded `/root/.gemini/skills/`) is removed — Harbor's native command uses
130163
the shell-expanded `$HOME`, correct for non-root users too.
131164

132165
## References
133166

134167
- `runner._collect_native_skill_dirs`, `runner._refresh_agent_skills`,
135-
`runner._agent_type_from_import_path`
168+
`runner._agent_type_from_import_path`, `runner._referenced_skill_dirs_for_agent`,
169+
`runner._warn_skill_basename_collisions`
170+
- `plugin_registration.collect_referenced_skill_dirs`,
171+
`plugin_registration.collect_plugin_skill_dirs` (dir-list channel),
172+
`stage_referenced_skills` / `register_plugin_skills` (unchanged Claude sink)
136173
- Harbor `agents/installed/codex.py::_build_register_skills_command`,
137174
`gemini_cli.py::_build_register_skills_command`, `trial.py` skill upload
138175
- `openai/codex` `core-skills/src/loader.rs` (native discovery roots)
139-
- [ADR-009](009-plugin-and-skill-by-reference.md) (the Claude skill path this
140-
decision deliberately leaves untouched)
176+
- [ADR-009](009-plugin-and-skill-by-reference.md) (the `[[skill]]` / plugin
177+
mechanism whose Claude sink this decision leaves untouched)
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Agent Instructions
2+
3+
## Approach
4+
5+
1. Begin by consulting the **nasde-dev** skill and apply its guidance throughout this task.
6+
2. Before writing any code, explore the existing codebase to understand its architecture and conventions.
7+
3. Follow existing architectural patterns exactly — match naming, namespaces, directory structure, and code style.
8+
4. When modifying nasde-toolkit functionality (CLI, runner, evaluator, config, agents), follow the `nasde-dev` skill's verification protocol, including documentation sync requirements.
9+
5. After making changes, verify documentation is consistent (CLAUDE.md, README.md, ARCHITECTURE.md).
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
agent = "codex"
2+
model = "gpt-5.4"
3+
4+
# nasde-dev skill by-reference (ADR-009) from the live source. For a Codex
5+
# variant this is registered through Harbor's native config.agent.skills so
6+
# the skill lands in $HOME/.agents/skills and Codex auto-discovers it — NOT in
7+
# the /app cwd (which Codex never scans). See ADR-012.
8+
[[skill]]
9+
path = "../../../../.claude/skills/nasde-dev"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Agent Instructions
2+
3+
## Approach
4+
5+
1. Begin by consulting the **nasde-dev** skill and apply its guidance throughout this task.
6+
2. Before writing any code, explore the existing codebase to understand its architecture and conventions.
7+
3. Follow existing architectural patterns exactly — match naming, namespaces, directory structure, and code style.
8+
4. When modifying nasde-toolkit functionality (CLI, runner, evaluator, config, agents), follow the `nasde-dev` skill's verification protocol, including documentation sync requirements.
9+
5. After making changes, verify documentation is consistent (CLAUDE.md, README.md, ARCHITECTURE.md).
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
agent = "gemini"
2+
model = "google/gemini-3-flash-preview"
3+
4+
# nasde-dev skill by-reference (ADR-009) from the live source. For a Gemini
5+
# variant this is registered through Harbor's native config.agent.skills so
6+
# the skill lands in ~/.gemini/skills and Gemini auto-discovers it — NOT in
7+
# the /app cwd (which Gemini never scans). See ADR-012.
8+
[[skill]]
9+
path = "../../../../.claude/skills/nasde-dev"

src/nasde_toolkit/plugin_registration.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@
1111
2. The ``[[skill]]`` array in variant.toml references a skill from a source
1212
path instead of copying it into ``variants/<v>/skills/``.
1313
14-
Both feed :func:`stage_skill_dir`, which expands a skill directory into the
15-
flat ``{container_path: host_file}`` mapping ConfigurableClaude uploads
16-
(it only uploads regular files, mirroring ``_collect_codex_skills``). The
17-
plugin path additionally calls :func:`inject_mcp_server` to register the
18-
plugin's MCP server in the task's Harbor config.
14+
For **Claude** both feed :func:`stage_skill_dir`, which expands a skill
15+
directory into the flat ``{container_path: host_file}`` mapping
16+
ConfigurableClaude uploads to ``/app/.claude/skills`` (Claude's cwd discovery
17+
root). The plugin path additionally calls :func:`inject_mcp_server` to register
18+
the plugin's MCP server in the task's Harbor config.
19+
20+
For **Codex/Gemini** the same skill *directories* are resolved by
21+
:func:`collect_referenced_skill_dirs` / :func:`collect_plugin_skill_dirs` and
22+
fed to Harbor's native ``config.agent.skills`` instead — those CLIs scan only a
23+
HOME-scoped dir (``$HOME/.agents/skills``, ``~/.gemini/skills``), never the
24+
``/app`` cwd the sandbox map targets. See ADR-012.
1925
"""
2026

2127
from __future__ import annotations
@@ -119,6 +125,54 @@ def stage_referenced_skills(
119125
console.print(f" [dim]Registered referenced skill '{resolved.name}'[/dim]")
120126

121127

128+
def collect_referenced_skill_dirs(
129+
variant_dir: Path,
130+
worktree_factory: WorktreeFactory,
131+
) -> list[Path]:
132+
"""Resolve the ``[[skill]]`` array to a list of whole skill directories.
133+
134+
Same resolution as :func:`stage_referenced_skills` (each entry's ``path``
135+
relative to the variant dir, optional ``ref`` read from a temporary git
136+
worktree) but returns the resolved *directories* instead of flattening them
137+
into a ``sandbox_files`` map under ``/app/.claude/skills``. Codex and Gemini
138+
auto-discover skills only from a HOME-scoped dir, never from the agent's
139+
``/app`` cwd, so they take this dir list through Harbor's native
140+
``config.agent.skills`` mechanism. Claude keeps using
141+
:func:`stage_referenced_skills`. See ADR-012.
142+
143+
Paths are fully ``resolve()``d so they compare equal to the snapshot dirs
144+
from ``runner._collect_native_skill_dirs`` — the union/dedup, stale-drop and
145+
basename-collision logic in ``_refresh_agent_skills`` key on string equality,
146+
which would break on a symlinked ``/tmp`` (macOS) or a ``ref`` worktree path.
147+
"""
148+
dirs: list[Path] = []
149+
for entry in _read_skill_entries(variant_dir):
150+
resolved = _resolve_skill_source(variant_dir, entry["path"], entry.get("ref", ""), worktree_factory)
151+
if not (resolved / "SKILL.md").exists():
152+
raise RuntimeError(f"[[skill]] '{resolved}' has no SKILL.md")
153+
dirs.append(resolved.resolve())
154+
return dirs
155+
156+
157+
def collect_plugin_skill_dirs(staged: StagedPlugin) -> list[Path]:
158+
"""Resolve a shipped plugin's own ``skills/`` to a list of skill dirs.
159+
160+
The dir-list counterpart of :func:`register_plugin_skills`, for the same
161+
reason as :func:`collect_referenced_skill_dirs`: Codex/Gemini take these
162+
dirs through Harbor's native ``config.agent.skills`` rather than a
163+
cwd ``sandbox_files`` mapping. Paths are ``resolve()``d for the same
164+
string-equality reason as :func:`collect_referenced_skill_dirs`.
165+
"""
166+
skills_root = staged.staged_dir / "skills"
167+
if not skills_root.is_dir():
168+
return []
169+
return [
170+
skill_dir.resolve()
171+
for skill_dir in sorted(skills_root.iterdir())
172+
if skill_dir.is_dir() and (skill_dir / "SKILL.md").exists()
173+
]
174+
175+
122176
def inject_mcp_server(task_dir: Path, staged: StagedPlugin) -> None:
123177
"""Write the plugin's MCP servers into the task's task.toml.
124178

0 commit comments

Comments
 (0)