Files
ironclaw/scripts/ci
Pranav Raja 89285c8e70 fix(skills): one DB-backed tree for every skill mount, and make a skill's own commands runnable (closes #7168) (#7171)
* fix(reborn): inject skill bodies by default, not a one-line listing

Reborn defaulted `SkillInjectionMode` to `Listing`, where a non-activated
skill contributes only `- name: description` to context and its body loads
only on an explicit `$name` mention or a `builtin.skill_activate` call. The
intent was to save context budget.

Benchmarking shows the model reads the menu and then never opens the skill.
Over 30 runs with human-curated skills installed (SkillsBench/SkillLearnBench
subset, `deepseek-v4-flash`, nearai/benchmarks#287):

  builtin.skill_list       called in 30/30 runs
  builtin.skill_activate   called in  3/30 runs
  a skill body actually read           0/30 runs

So installed skills were effectively inert. Same 31 tasks, same skills, same
model, varying only this default:

  no skills                 78.5%
  curated skills, Listing   79.8%   (+1.3pp -- skills bought almost nothing)
  curated skills, Full      85.6%   (+7.1pp)

For reference, harnesses that inject skill bodies unconditionally (Hermes,
Claude Code) score 91.5% on these tasks with the same skills, so `Full` closes
most but not all of that gap; the remainder is loop/verification behavior on a
handful of multi-output tasks and is tracked separately.

`Full` is already the library default in `SkillActivationSelectorConfig`; only
the Reborn composition seam opted out. This restores it and adds a guard test
so a revert is deliberate. `IRONCLAW_REBORN_SKILL_INJECTION=listing` still
selects the previous behavior where context budget matters more than skills
being used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(skills): hot-swappable activation strategies so agent-authored skills are reusable

Adds `skill.activation.v1`, a swappable-provider module in the shape of the
memory-provider binding (`ironclaw_host_runtime::memory_binding`): named
strategies, fail-closed resolution, behavior-preserving default, and a
composition seam so nothing downstream names a concrete implementation.

## The bug it addresses

`selector::score_skill` accumulates score ONLY from `activation.keywords` (+10/+5),
`activation.tags` (+3) and `activation.patterns` (+20). A skill's `name` and
`description` contribute nothing, and `select_skills` keeps a skill only
`if score > 0`.

That is fine for curated skills, which ship an `activation` block. It is fatal for
skills an agent writes for itself: measured across the 31-task
SkillsBench/SkillLearnBench subset in nearai/benchmarks#287, **0 of 30**
agent-authored skills contained an `activation` block. Every one scored 0 and was
permanently unselectable — the agent could create a skill via
`builtin.skill_install` and then never reuse it, which makes self-improvement
structurally impossible rather than merely weak.

Claude Code has no such requirement: a skill is selectable from name and
description alone. `ActivationStrategy::NameAndDescription` ports that contract.

## Design

* `CriteriaOnly` (default) — today's rule, byte-identical.
* `NameAndDescription` — whole-word name/description fallback, applied ONLY when
  the criteria pass scored 0, so a curated skill's explicit keywords always decide
  ordering and this can never reorder two skills that both declare metadata.
  `NAME_WORD_SCORE` (8) is deliberately below the selector's exact-keyword award
  (10).
* `Disabled` — explicit mention / `skill_activate` only.
* `ThirdParty { extension_id }` — production requires an admin override.

Whole-word matching and a `MAX_FALLBACK_SCORE` cap keep it from over-selecting;
over-selection is the failure mode that makes injecting an unrelated skill bank
harmful (a whole-catalog injection took `xlsx_recover_data` 1.000 -> 0.271).

## Default stays behavior-preserving

Reborn's default remains `CriteriaOnly`, opt in with
`IRONCLAW_REBORN_SKILL_ACTIVATION=name_and_description`. Flipping the default
changes three existing local-dev expectations (setup-marker suppression, the webui
listing candidate, `skill_activate` context loading), so the strategy ships opt-in
— the same discipline as the memory work, where the bundled native provider stays
the default.

## Tests

`cargo test -p ironclaw_skills --lib` — 239 passed, including:
* `agent_authored_skill_unreachable_by_default_but_selected_under_name_strategy`
  — end-to-end via `prefilter_skills_with_options`: the same
  no-activation skill is dropped under `CriteriaOnly` and selected under
  `NameAndDescription`.
* `name_strategy_does_not_select_an_irrelevant_skill` — no over-selection.
* `name_hit_outranked_by_an_explicit_curated_keyword`, `whole_word_only_...`,
  `fallback_is_capped_...`, `stop_words_do_not_accumulate_score`.

`cargo test -p ironclaw_first_party_extension_ports --lib` — 58 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(reborn): ship the Full skill-injection default as opt-in, not a flip

The measurement in the previous commit stands: `Listing` leaves installed skills
unread (`skill_list` 30/30 runs, a body actually opened 0/30) and `Full` is worth
79.8% -> 85.6% on the 31-task SkillsBench subset.

But flipping the product default HANGS three existing local-dev tests, which drive
a mock that expects the one-line listing candidate:

* `local_dev_skill_activate_tool_loads_selected_skill_context`
* `local_dev_webui_bundle_records_selectable_filesystem_skill_context`
* `local_dev_runtime_wires_filesystem_skills_by_default_to_model_calls`

Verified by bisect: all three hang on the previous commit alone, and pass with the
default restored — the activation-strategy work is not implicated.

Changing a documented product default in a way that turns CI red is a maintainer
call, not something to force through, so `DEFAULT_SKILL_INJECTION_MODE` returns to
`Listing` and `Full` ships as `IRONCLAW_REBORN_SKILL_INJECTION=full`. Both switches
in this PR are now opt-in with the evidence attached, matching the memory-provider
discipline where the bundled default is preserved.

The guard test is retargeted to assert the current default, verify the opt-in path
still resolves, and name the three tests that must be updated alongside a future
flip.

cargo test -p ironclaw_reborn_composition --lib -- skill_injection_mode \
  local_dev_selector_config skill_activation  # 14 passed, 0 failed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(threads): raise the result_read cap to 64 KiB, env-tunable

A small per-request `result_read` cap turns one large file into a paging loop. On
`manufacturing_equipment_maintenance` (nearai/benchmarks#287) reborn made 8
`read_file` calls and ZERO shell calls, hit the 24 KiB cap, then spent the whole
turn paging — `result_read` at offset 24576, `handbook.pdf` at offsets
400/800/1200 — and never computed anything (`outputs_exist=0.00`). hermes, using
shell to sample the same data, scored 0.522.

* `TOOL_RESULT_RECORD_READ_MAX_BYTES` 24 KiB -> 64 KiB. This is the compile-time
  ceiling the model-observation envelope in `tool_result_reference.rs` is derived
  from (`* 2`, asserted at compile time), so 64 KiB here means a 128 KiB envelope —
  the reason not to go higher.
* `TOOL_RESULT_RECORD_READ_DEFAULT_MAX_BYTES` = 64 KiB — the effective default.
  Enough that a typical data file or document page arrives in one read instead of a
  paging loop.
* `IRONCLAW_TOOL_RESULT_READ_MAX_BYTES` overrides it, clamped to `[4, ceiling]`, so
  an override can never outgrow the envelope. Unparseable values fall back to the
  default rather than failing the run — a malformed tuning knob must not take down
  an agent.

Unlike the skill-injection and skill-activation switches in this branch, this one
does move the default: the paging loop is a silent capability loss rather than a
behavior preference, and the knob exists for deployments that want the old size.

cargo test -p ironclaw_threads --lib   # 88 passed (85 existing + 3 new)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(skills): add always_available activation, Claude Code's actual contract

`skill.activation.v1` gains a third binding, `always_available`: every installed
skill is a candidate regardless of what it matches.

This is what Claude Code and Hermes actually do. In both, a skill is a file in a
directory the agent can read, so there is no gate for a correctly-installed skill
to fail. Reborn's selector instead scores only `activation.keywords`/`tags`/
`patterns` and drops anything scoring 0 -- and `name_and_description` (this
branch's earlier binding) only WIDENS that gate: it still needs a lexical hit, so
an applicable skill phrased differently from the prompt is still discarded. The
new test pins exactly that case -- a skill described as "cyclical component /
growth path" against a prompt saying "hp filter" is dropped by both
`criteria_only` AND `name_and_description`, and kept by `always_available`.

Why it matters, measured on the 31-task SkillsBench/SkillLearnBench subset in
nearai/benchmarks#287: 0 of 30 agent-authored skills contained an `activation`
block, so under `criteria_only` a self-authored skill could never be selected
again -- self-improvement was structurally impossible.

Implementation is deliberately tiny: a `floor_score()` of 1 for this binding,
applied via `.max()` in the selector's existing scoring loop. Ordering is
untouched (a real keyword match still outranks a floor skill, so the context
budget spends on the relevant skill first), and the existing budget -- not the
score filter -- decides what is injected, which is also how Claude Code behaves.
`floor_score()` is 0 for every other binding, so non-adopters are byte-identical.

Default remains `criteria_only`; opt in with
IRONCLAW_REBORN_SKILL_ACTIVATION=always_available.

cargo test -p ironclaw_skills --lib   # 241 passed (239 existing + 2 new)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(threads): drop the now-unused ceiling import

Validation bounds against `contract::effective_tool_result_read_max_bytes()` (which
applies the env override), so the compile-time ceiling is no longer referenced here.
Removes an unused-import warning introduced by the 64 KiB cap commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* revert(threads): default result_read back to 24 KiB, keep the knob

The raise to 64 KiB was never isolated: it shipped in a measurement arm alongside two
other switches (skill activation, tool disclosure), so there is no evidence it changed
anything. Defaulting it back keeps this crate byte-identical to pre-PR behavior.

The paging trace that motivated it is real (`manufacturing_equipment_maintenance`,
nearai/benchmarks#287: 8 `read_file` calls, zero shell calls, `result_read` at offset
24576, nothing computed) — but a real trace is not a measured fix, so the larger cap
stays opt-in via IRONCLAW_TOOL_RESULT_READ_MAX_BYTES for whoever wants to measure it
properly.

The compile-time ceiling stays 64 KiB: it now bounds only how far the env override may
reach, and still pins the derived model-observation envelope at 128 KiB. Net effect of
this commit plus its parent: a new env knob, no default change.

cargo test -p ironclaw_threads --lib   # 88 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skills): design for agent-authored multi-file skill bundles

@henrypark133 pushed back on "move skills to the filesystem" as an overhaul that a
single aggregate result did not justify. He was right, and stratifying the data shows
why: the entire filesystem gain sits in skills that ship files besides SKILL.md.

  ships resource files (n=16): inject 81.0% -> files 94.2%  (+13.2pp, CI [+0.3, +26.2])
  SKILL.md-only        (n=11): inject 91.5% -> files 84.7%  (-6.9pp,  CI [-20.4, +6.7])

So filesystem-for-everything is a REGRESSION on 13 of 31 tasks, paid to fix the other 18.
The mechanism is not "models prefer filesystems": 81 of the resources are executable
(you cannot run pasted Python -- citation_check scored 0.000 with the script absent,
0.833 with it present), and the text resources are too large to inline
(exceltable_in_ppt would be ~262k tokens folded into SKILL.md).

The design therefore keeps storage, discovery and selection exactly as they are and adds
ONE extension holding the already-existing `/skills` read_write mount:
skill_write_file / skill_read_file / skill_list_files. Discovery already lists from the
same root that mount writes to, so nothing needs plumbing. Executing a bundled script
copies that one file into `/workspace`, which the agent already mounts.

Documents two things the implementation must not miss: SkillBundleDescriptor exposes only
`skill_md_path`, so bundle resources are un-advertisable without skill_list_files; and
`FilesystemSkillBundleRoot::user` marks bundles Trusted, so an agent that can write
executable scripts there needs a distinct trust level -- the real open question.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skills): state explicitly that creation, discovery and indexing are unchanged

The crux of @henrypark133's objection. Spells out, per concern, that skill creation stays
on the `skill_install` tool, discovery stays on the storage-agnostic
`SkillBundleSource` trait with no new impl / trait method / descriptor change, and that
there is no session-start index to migrate at all (selection is per-request; the only
cache is a 5-minute TTL on catalog search). The single behavioral change remains the
opt-in `always_available` selection predicate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skills): the write tool needs an authoring prompt that asks for code

skill_write_file makes multi-file skills possible; it does not elicit them. Measured: 6 of
31 tasks finished with ZERO skill_install calls despite 'Saving the skill is required', and
the authoring request only ever asks for prose (method, conventions, output contract). An
agent following it writes prose whether or not a write tool exists.

Adds the elicitation requirement and a falsifiable success criterion: agent-authored
bundles are currently 100% prose (0 of 27 ship a resource file) against 18 of 31 curated
skills. If that ratio does not move once the tool ships, the bottleneck was elicitation
rather than capability and the tool alone will not move scores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(skills): let an agent install skill bundles, not just prose

Agents could only ever author the PROSE half of a skill. Measured on the 31-task
SkillsBench/SkillLearnBench subset (nearai/benchmarks#287): **0 of 27** agent-authored
skills shipped a single file besides SKILL.md, against **18 of 31** human-curated ones
(79 .py scripts, 78 .xsd schemas, 84 .md references). So every later run re-derived the
method from prose and could re-make the same mistake -- lake_warming's self-authored skill
described its regression procedure in prose, the next run recomputed it slightly
differently and missed the grader's p<0.05 threshold.

This was NOT a missing capability. `install_skill` has always taken
`files: &[SkillInstallFile]`, and `parse_install_files` has always read an `input["files"]`
array. Two things made it unreachable:

1. `schemas/builtin/skill_install.input.v1.json` advertised only `name`/`content`/`url`
   AND set `additionalProperties: false` -- so a model sending `files` was not merely
   uninformed, it was REJECTED. Across 112 observed skill_install calls, 111 used exactly
   `['content','name']`, which is what the schema permits.
2. The only encodings were `bytes_base64` and a JSON array of byte integers. A bundle file
   an agent writes is a script, a reference doc or a schema fragment -- all UTF-8. Making
   those go through base64 costs ~33% more tokens and turns one encoding slip into an
   InputEncode failure of the whole install.

Changes:
- `parse_install_files` accepts `text` (UTF-8) alongside `bytes_base64`/`bytes`. `text`
  takes precedence when both are given, matching the documented preference. Binary
  payloads are unaffected.
- the schema advertises `files` with `path` + `text`/`bytes_base64`, and the description
  tells the model WHY to use it: put a reusable computation in a script rather than
  describing it in prose, and have SKILL.md name the files it relies on. That last part
  matters because `SkillBundleDescriptor` exposes only `skill_md_path`, so a bundle cannot
  advertise its own resources.
- prose-only installs are untouched: no `files` key still parses to an empty vec.

cargo test -p ironclaw_first_party_extensions --lib install_files_encoding  # 4 passed
cargo test -p ironclaw_host_runtime --test tool_surface_contract            # 43 passed
cargo test -p ironclaw_reborn_composition --test product_live_adapters skill_install  # 1 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skills): stop rejecting an install that carries both content and files

`skill_install_input` gated the direct-install arm on `!object.contains_key("files")`, so
`content` + `files` matched NO arm and fell through to `_ => Err(InputEncode)`. An agent
attaching a script had its ENTIRE install refused. `files` was reachable only on the
URL-fetch arm, which builds the array itself.

This was the third of three stacked gates hiding the same capability, and the one that
actually bit. With the schema fixed to advertise `files` and a `text` encoding available,
the model on the 31-task SkillsBench subset (nearai/benchmarks#287) immediately sent 18
correctly-shaped `{path, text}` entries across 9 calls -- `scripts/verify_bib.py`,
`references/fake_patterns.json` -- and every one was rejected here. That is the real reason
0 of 27 agent-authored skills shipped a resource file while 18 of 31 human-curated ones do:
not a missing capability, and not the model failing to try.

`source`/`source_url` stay excluded from the direct arm: those record provenance and are
set by the URL path, so an agent must not be able to forge them.

cargo test -p ironclaw_host_runtime --lib skill_install_input   # 4 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* style: rustfmt the skill-bundle and activation changes

Test modules were appended programmatically without rustfmt, which is why Formatting,
Code Style and Clippy all went red on this PR.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skills): rewrite to match what was measured, not the abandoned design

The doc recommended a three-tool extension plus a resource-gate. Both are superseded:
the tools turned out to be redundant (install_skill already accepted files -- three
stacked gates were hiding it), and the gate MEASURED WORSE than always advertising a
readable path (-25.7pp on self-creation, -40.6pp vs claude-code), because an
agent-authored skill is usually SKILK.md-only so the gate suppresses the one route the
selector had not already closed.

Rewritten around the durable findings: the three gates and how each masked the next, the
0-of-27 vs 18-of-31 measurement, the SkillBundleDescriptor enumeration gap, and the trust
question. The gate is kept in the doc as a recorded negative result, since its stratified
justification is persuasive and will be proposed again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(skills): correct why always_available is not the default

The previous note claimed the floor score overrides setup-marker suppression. It does not:
`prefilter_skills_with_options` returns None for a satisfied marker BEFORE scoring, and
the host-side filter in activation.rs already removed the candidate.

What actually fails: all 32 bundled skills reach floor 1, so 3-4 unrelated ones land in
plan.activations() in ActivationCriteria mode -- a mode that injects nothing under
Listing. The defect exposed is that a criteria activation which injects no body is still
recorded as an activation, so the count assertions stop being meaningful.

Also records the sequencing against epic #6565 (Slice 0 first; Slice 5's bounded-shortlist
rule constrains what an unbounded floor may do) and the measured detail that under Listing
a zero-scoring skill is still listed -- the model just called skill_activate in only 3 of
30 runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skills): a floor-only skill is ranked, not activated

Three defects, all surfaced by trying to make `always_available` the default. It failed 8
tests in ironclaw_reborn_composition; all 8 now pass with the flag on AND off.

1. A criteria selection that injects nothing was still recorded as an activation. Under
   `SkillInjectionMode::Listing` an `ActivationCriteria` entry contributes no body --
   `body_eligible_bundle_ids` already ignores that mode -- so with a floor score every
   installed skill "activated" on every turn. Concretely: all 32 bundled skills reach floor
   1, and 3 of them (6000 token budget / 2000 default per-skill cost) landed in each plan,
   chosen by descriptor order because the score sort is stable. `SelectionOutcome` now
   returns those separately as `ranked_only`, and the activation path does not iterate them.
   They still reach the model through the listing, which is where they belonged.

2. `AlwaysAvailable` also enabled the name/description fallback, which manufactured fake
   merit: a bundled skill whose description shares one word with the message scored above
   zero and was reported as a genuine activation. Under `AlwaysAvailable` the fallback adds
   no reach at all (the floor already admits everything), so it is now scoped to
   `NameAndDescription`, where widening the match is the entire point. This is what kept
   `local_dev_runtime_suppresses_explicit_setup_skill_when_workspace_marker_exists` failing
   after (1).

3. Raising TOOL_RESULT_RECORD_READ_MAX_BYTES to 64 KiB was NOT the no-op this PR claimed.
   `tool_result_reference.rs` derives MAX_MODEL_OBSERVATION_BYTES from it (* 2), so the
   observation envelope silently doubled 48 KiB -> 128 KiB and preview truncation changed
   for every caller. It broke three tests whose fixtures are sized against the envelope
   ("fixture must exceed the preview cap"), independently of any activation setting. The
   contract ceiling is back to 24 KiB and the env override is bounded by a new
   TOOL_RESULT_READ_ENV_CEILING_BYTES that nothing is derived from -- so the knob can raise
   a single read without moving anyone else's behavior.

Correcting the record on an earlier comment in this PR: the failures were never the
setup-marker interaction. Marker suppression returns None before scoring, so a floor score
cannot revive a suppressed skill.

cargo test -p ironclaw_reborn_composition --lib                                    # 634 passed
IRONCLAW_REBORN_SKILL_ACTIVATION=always_available cargo test -p ironclaw_reborn_composition --lib  # 634 passed
cargo test -p ironclaw_skills --lib  # 241    cargo test -p ironclaw_threads --lib  # 88

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* revert(skills): remove the always_available strategy, it bought nothing

Verified against this branch: `AlwaysAvailable` was a no-op for everything the model can
observe, and it carried a regression. Removing it rather than wiring a compensating half.

Why it bought nothing. Listing membership is decided by VISIBILITY, not selection
(extension_ports/activation.rs partitions candidates on body-eligibility, and everything
not body-eligible still goes into the listing), so the model was ALREADY shown every visible
skill before this strategy existed. The floor score never added reach -- pre-C1 its only
effect was listing ORDER, and after C1 excluded floor-only skills from activations even the
ordering effect was gone, because the ranking input is derived from the activation list.
`SelectionOutcome::ranked_only` had no production reader at all: allocated, populated,
returned, dropped. Under `Full` a floor-only skill could never be injected either, since
`context_candidates_for_plan` renders only activated bundles.

The regression. The floor-only bookkeeping ran for every non-merit entry BEFORE `try_select`,
so under this strategy a chain-loaded companion got its own loop iteration, was recorded as
floor-only, and was then partitioned OUT of `selected` -- i.e. `A requires B` activated only
`A`, where `CriteriaOnly` activates both. Strictly worse than the default for any bundle with
companions, and order-dependent. The comment claiming this could not happen was wrong.

Also removed: ~29 "budget exhausted" notes per turn that reached `feedback` and fired a
SkillActivation live-projection event with empty skill_names, because floor-only skills still
ran the budget loop and `BudgetFull` continues rather than breaks.

Kept: `NameAndDescription`, which has a real effect (matching on name/description, not only
`activation.keywords`/`tags`/`patterns`), and the `skill.activation.v1` seam.

Corrects the record in two places that argued the opposite: the runtime.rs doc comment and
docs/skills/agent_authored_bundles.md. The measured reachability gap is elicitation, not
filtering -- `builtin.skill_activate` was called in 3 of 30 runs and a body read in 0 of 30 --
so the next step is the listing header, not a scoring change. Note the parity numbers in
nearai/benchmarks#327 never depended on this strategy: those arms ran with it off.

cargo test -p ironclaw_reborn_composition --lib   # 634 passed
cargo test -p ironclaw_skills --lib               # 240 passed
cargo test -p ironclaw_threads --lib              # 88 passed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(skills): explain refusals, gate requirements, survive discovery limits

Epic #6565, the missing/unusable half. Four causes it names, each of which was
silent.

## A refusal now says why

`select_named_skill_activations` reported a skill that exists but is not `Trusted`
with the same string as a name that does not exist: "requested skill is not
available". The two need opposite responses -- one means "try a different name",
the other means "this needs promoting and no name will work" -- and the model got
neither.

This is the routine outcome of the model doing what it was told: the listing
filters on visibility only while activation requires `Trusted`, and tenant-shared
and URL-installed skills are `Installed`. So the listing advertises a skill and
activation then refuses it.

Deliberately does NOT enumerate alternatives, tempting as that was:
`load_named_activation_candidate_set` scopes the candidate set to the requested
names, so nothing else is loaded at that point and any "available: ..." list
would be empty. I wrote that branch, found it could never fire, and removed it
rather than ship a message that lies. Offering alternatives needs a wider
descriptor load and belongs with #4428.

## Requirements are actually checked

`requires.bins`, `requires.env` and `requires.config` were parsed into the
manifest and never consulted. `check_requirements` exists, but its only callers
are inside `SkillRegistry`, which has no consumers outside its own crate. A skill
declaring a binary it needs was offered, activated cleanly, and failed later in
the shell with nothing connecting the failure to the unmet requirement.

Gated at ACTIVATION time, on both the explicit-mention and model-selected paths.
Not at listing time: that would be three probes per visible skill on every prompt
build and needs a caching design first. At activation it runs for the handful of
skills being loaded, so the cost objection does not apply. Staying unusable is
correct here -- the fix is that the reason reaches the model.

## One oversized root no longer erases itself

`list_root` returned `BundleScanLimitExceeded` when a root held more than
`max_bundles_per_root` directories, which removed EVERY skill in that root from
the model's view. A catalog that grew past the cap lost all its skills at once,
with no signal to the model and only a propagated error to the operator. Now it
keeps the bundles that fit and warns about the truncation.

## Silent skips became warnings that name the reason

Two `debug!` sites -- an invalid bundle directory name, and a manifest that fails
validation (which covers the common authoring mistake of a directory name
disagreeing with the manifest `name:`) -- meant a skill present on disk simply
never appeared and nothing said why. Both are `warn!` with the error attached.

## Tests

Three that pinned the old behaviour were rewritten rather than deleted, each with
why: the two refusal-message assertions, and the scan-limit test that asserted
total root loss. New coverage for the trust-vs-name distinction and for an unmet
binary requirement being refused with the requirement named.

`cargo test -p ironclaw_first_party_extension_ports -p ironclaw_loop_host` — 68 +
420 + 27 + 4 + 88 pass. fmt and clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): surface the refusal reason to the model, not just the projection

The reason strings were the easy half. `skill_activate`'s result was built from
`plan.selection.activations` only and **discarded `plan.selection.feedback`
entirely**, so every reason the selector produces was constructed and thrown away.
The model saw `{"activated":[],"count":0}` and had to guess whether it had used a
bad name, hit a trust wall, or tripped an unmet requirement -- three situations
that need three different responses.

Caught by measurement rather than review: on the missing/unusable fixtures
`usable` moved after the earlier commit but `diagnosed` stayed flat at 2/7,
because improving the wording of a message nobody receives changes nothing.

Adds `not_activated` alongside `activated`. Routine "activated after model
selection" confirmations are filtered out -- next to `activated` they are noise
and would dilute the refusals that matter.

Output construction is extracted into `build_activation_output` so the contract is
unit-testable in the same style as the rest of the module: a refusal carries its
reason, a clean activation gains no empty field, and a mixed result reports both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(skills): the model decides, and the listing can never be hidden from it

Epic #6941 criteria 1 and 8. Adopts @serrrfirat's position -- "I don't think we
should statically regex match skill names to skill activation... we should show it
to the model and it should decide" -- as the default rather than a flag.

## The default is now ExplicitOnly

`SkillActivationSelectorConfig::default()` no longer runs the keyword/regex scorer.
A profile that wants it must ask for `ExplicitAndCriteria` deliberately; nothing
inherits it silently, which is how #5417 shipped.

The scorer's own record is the argument for retiring it:

* It produced #5417 -- `tech-debt-tracker` declares the keyword `hack`, so "search
  Hacker News for..." activated it.
* Over 328 real prompts `coding` fired on ~220 through *legitimate whole-word* hits
  on `file`/`change`/`code`. No boundary rule or score threshold can fix that.
* Measured against it, the model path made **zero** wrong selections across 28
  tasks over an 88-skill catalog, at **94.8%** precision on what it did activate.

The scorer is not deleted: it is still correct, still tested, and still reachable
for a profile that opts in. It is simply no longer the thing that decides.

## And a trap that had to be fixed in the same commit

In `Full` injection mode both context paths returned an EMPTY candidate set when
nothing was active. That was survivable only while the scorer auto-activated
something. With model-decides it would mean the model is never told a skill exists
and therefore can never activate one -- flipping this default alone would have
blinded the agent. Both paths now fall through to the listing.

## Tests

Three new criterion tests: the default policy is pinned (so a silent revert fails),
the listing survives with nothing activated in BOTH injection modes, and the
listing stays inside a stated character budget at 200 skills -- with the scorer
retired the listing IS the routing interface, so its size is a correctness property
rather than a cosmetic one.

Sixteen existing tests were updated rather than deleted, in two groups, each with
its reason in the code:

* Tests whose SUBJECT is the scorer now call `criteria_config()` to opt in. That is
  the point of the new default -- nothing gets the scorer by accident. Includes
  `prepare_grants_asset_reads_only_for_activated_bundles`, which uses criteria
  selection only as the mechanism to activate a bundle; I checked that one first
  because an asset-grant assertion failing could have been a real security
  regression rather than an expectation change. It was not.
* Tests asserting `selected.is_empty()` now assert
  `assert_no_skill_body_disclosed(..)`. "No candidates" is no longer the right
  question; "no skill BODY reached the model" is what they were really protecting,
  and it is exact -- the listing is a *discoverable* candidate
  (`loaded_skill_md() == None`) while an activated skill is a *loaded* one.

`cargo test -p ironclaw_first_party_extension_ports -p ironclaw_loop_host
-p ironclaw_skills` -- 74 + 420 + 27 + 4 + 88 + 229 pass. fmt and clippy clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): pin #5417 on the path that records the user message

Epic #6941 criterion 2. Criteria selection needs a RECORDED user message
(`take_message_for_run`), and the coordinator path never records one -- so a
coordinator-path test passes vacuously and proves nothing. This records the
message, which is what the product/WebUI surface does, and the issue itself
reports "Run origin: WebUI chat".

Asserts BOTH policies, including the uncomfortable one:

* model-decides (the new default): no body is injected. Fixed.
* criteria explicitly enabled: it STILL mis-activates on this branch. Asserted as
  a known residual rather than omitted.

That second arm is the useful half. It shows the two changes are complementary
rather than redundant: this PR removes the scorer from the decision, #6937's
word-boundary matcher stops `hack` matching inside "Hacker" for any profile that
opts the scorer back in. Neither alone closes #5417 on the criteria path, and
pinning it here means a future reader cannot mistake model-decides for a complete
fix. The assertion message says what to do when #6937 merges and the arm flips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): a truncated skill listing must say so

`MAX_LISTED_SKILLS` is 100 and the listing is source-then-name ordered, so past
the cap whole alphabetical tails vanish -- with no signal to the model and none to
the operator.

Found by walking into it. Running Benchmark A against a 227-skill catalog, `pdf`,
`pptx`, `xlsx` and `timeseries-detrending` all sorted past position 100, so three
of the first four tasks could not reach their own expected skill and the arm was
measuring nothing. Nothing anywhere reported it; I only caught it by diffing the
rendered listing against each task's expected set. That is the failure mode this
epic exists to remove, and it was hiding in the listing itself.

Now the listing states how many skills are hidden, and the host logs a warning
with listed/hidden/total.

This does NOT make a large catalog usable -- that needs `skill_search` (#4428),
and a 227-skill listing costs ~9k tokens of prompt besides. What it does is turn a
silent, invisible failure into a stated one, so a benchmark or a user hitting the
cap finds out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): make "the model decides" reachable, and list every skill instead of the first 100

Two defects found by measuring this PR rather than reviewing it. Both made a claim
the PR already made untrue in practice.

**1. The ExplicitOnly default was dead code on the Reborn path.**

`skill_activation_selector_config` pinned `ExplicitAndCriteria` at the call site, so
changing the default in `activation.rs` could not affect any real Reborn user. The
model-decides change looked like a behaviour change and was not one. No test in
`ironclaw_first_party_extension_ports` could catch this, because those construct
their own config; only a test on the value the composition layer actually returns
can. `reborn_skill_selection_is_model_decided` is that test, and it fails if the
mode is re-pinned.

**2. The skill listing silently dropped everything past 100 skills.**

`MAX_LISTED_SKILLS = 100` with source-then-name ordering meant whole alphabetical
tails were rendered nowhere and logged nowhere. Measured on a 227-skill catalog:
`pdf`, `pptx`, `xlsx` and `timeseries-detrending` all sorted past the cap, and three
of the first four benchmark tasks could not reach their own expected skill. A skill
the model cannot see is one it cannot activate, so this is indistinguishable from
never having installed it — and with the scorer retired, the listing IS the routing
interface, which makes its completeness a correctness property.

The flat count cap becomes a character budget spent differently: every skill's name
is listed, with per-entry descriptions shrinking as the catalog grows (250 chars at
small sizes, 90 at 227), and entries are dropped only when even 60 chars will not
fit — roughly past 380 skills. **This is not a context-size increase.** The budget
is exactly what the old cap already permitted (`100 * (250 + 64)`). What changes is
that it buys reachability for all skills rather than verbosity for the
alphabetically lucky first hundred. When truncation does happen it is stated in the
listing and warned with `listed`/`hidden`/`total`.

`the_listing_stays_within_budget_at_two_hundred_skills` now asserts both that the
listing fits its budget *and* that all 200 skills appear in it. The second assertion
is the one the old cap violated: that test previously passed on budget alone while
hiding half the catalog, which is how this survived.

Beyond ~380 skills the answer is `skill_search` (#4428), not a bigger prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): raise the per-root bundle scan cap so a real catalog enumerates whole

Found by measuring: after replacing the listing's 100-skill cap with a character
budget, a 227-skill root STILL only reached the model as 100 skills. The listing was
never the binding limit.

`DEFAULT_MAX_BUNDLES_PER_ROOT = 100` truncates one layer earlier, at enumeration:
`skill bundle root exceeds the per-root scan limit ... limit=100 skipped=127`. The 127
skipped bundles were invisible to the selector, to the listing, and to the model —
the same outcome as never installing them. Two caps, and only the lower one decides,
so raising the listing budget alone accomplished nothing.

Raised to 512. The cap exists to bound an unbounded directory walk, not to bound a
catalog; 512 keeps that protection (a bundle is one directory read plus a manifest
parse, cached per root) while leaving real catalogs whole. Past it, truncation is
still partial-and-warned rather than fatal, and the answer is `skill_search` (#4428)
rather than a larger number.

`a_two_hundred_and_twenty_seven_skill_root_enumerates_whole` asserts the default cap
does not truncate a real catalog. The existing test only proved truncation *degrades
gracefully*, which is why a default too low to fit anything real passed it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): let the scorer rank the listing, while the model alone decides

Retiring the criteria scorer from ACTIVATION is right and stays. Retiring it from
ORDERING was a mistake, and the benchmark caught it.

Measured on the 31-task routing benchmark, 88 candidates, same model, paired:

|                          | criteria on | ExplicitOnly |
|--------------------------|-------------|--------------|
| >=1 correct skill        | 57.1%       | 28.6%        |
| recall over expected set | 42.9%       | 25.0%        |
| correct skill REQUESTED  | 75.0%       | 35.7%        |
| never called skill_activate | 25.0%    | 57.1%        |

The model was not being refused -- refusals were 0% in both arms. It stopped asking.
The scorer was the only thing making a long listing legible: with it off, the listing
collapses to source-then-name alphabetical order, and the relevant skill sits among
dozens of equally-weighted lines with nothing marking it. At 227 candidates this is
worse, not better.

So the scorer keeps its useful job and loses its harmful one. Under `ExplicitOnly` the
prefilter still runs, and its output populates `SkillActivationSelection::ranking_only`,
which feeds `criteria_ranked_bundle_ids` and therefore listing order ONLY. It never
appends to `activations`, and it deliberately does not extend `feedback` (those notes
explain activation decisions, and nothing was activated).

This is the distinction @serrrfirat's objection actually draws. "Don't statically regex
match skill names to skill activation" is not "don't use scoring to rank what the model
is shown". Ordering a menu is not choosing from it. The host recommends; the model
decides; a wrong recommendation costs a listing line rather than the skill budget.

`explicit_only_ranks_the_listing_without_activating_anything` pins both halves in one
test, because they pull in opposite directions: the matched skill must LEAD the listing,
and no body may be disclosed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "fix(skills): let the scorer rank the listing, while the model alone decides"

This reverts 2f8acabe7. I added it, and neither of its justifications survived.

The reasoning was already withdrawn publicly: I claimed retiring criteria selection
collapsed the listing to alphabetical order and made the model stop asking, but **0 of 62
benchmark catalog skills declare an `activation:` block** (32 of 32 bundled ones do), so
the scorer could never rank a single skill these tasks need. Listing order for every
expected skill was identical in both arms and the mechanism did not exist.

The data now declines to support it too: **33.3% >=1-correct with ranking vs 40.0%
without**. That is worse than neutral, and the reason is the same fact -- the only skills
the scorer CAN rank are the 32 irrelevant bundled ones, so ranking promotes distractors
above the skill the task actually needs. On any realistic catalog, where users' and
agents' skills carry no activation metadata, ranking is systematically wrong.

Also: the drop I built this on was small-n noise. The arm I read as 28.6% reads 40.0% at
n=25.

Worth revisiting only if descriptor metadata coverage ever becomes the norm rather than
the exception. Until then #6938 ships no heuristic in the selection path at all, which is
the point of the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): tell the model what a skill is FOR, not just what the tool does

Studied how claude-code routes to skills and ported the mechanism. It reaches 60% correct
activation on 5 SkillsBench tasks over a 227-skill catalog; reborn reached 0% on the same
tasks and the same catalog. The gap was not discovery -- every skill was listed and reachable
-- and it was not refusals, which were 0%. The model simply had no reason to ask, so it solved
each task with `shell` instead.

Three differences, in order of how much they mattered.

**1. The tool description.** local-dev's said:

    "Activate one or more listed Reborn skills for the current loop run"

That states what the tool does and nothing about when to use it or why. claude-code's states
what a skill IS, that a listing exists, that a matching skill should be activated FIRST, and
-- the load-bearing clause -- that the loaded instructions REPLACE the model's default
approach. A model that does not know a skill supersedes its own plan has no reason to prefer
one. Both descriptors now carry the same text; they had drifted, and local-dev (what every
local user gets, and what the benchmark measures) had the weaker one.

**2. Full descriptions for every skill.** The listing budget previously shrank per-entry
descriptions to fit more names -- 90 chars each at 227 skills. That traded away the wrong
thing: 52% activation with 88 full-length entries, 0% with 227 shrunken ones. Names make a
skill addressable; descriptions are what let the model judge relevance, and 90 chars does not.
claude-code pays this cost outright, listing every skill with its whole one-line description,
so the budget is now sized to do the same. The per-entry cap and the 512-bundle enumeration cap
still bound it -- this is a budget for a real catalog, not a licence for an unbounded one.

**3. The listing header** now opens the way claude-code's system-reminder does ("The following
skills are available for use with builtin.skill_activate") and repeats the supersedes-your-
default-approach point where the model reads the menu, not only where it reads the tool schema.

Measured after the port, same 5 tasks, same 227 candidates, same model:

|                     | before | after | claude-code |
|---------------------|--------|-------|-------------|
| >=1 correct         | 0%     | 50%   | 60%         |
| recall over expected| 0%     | 33.3% | 36.7%       |
| precision           | --     | 100%  | 100%        |
| never activated     | 100%   | 50%   | 40%         |

Precision is identical: when reborn now activates, it is not wrong. Recall still trails, and
the remaining gap is tasks where it never consults the catalog at all -- the same failure mode
claude-code has, just more often.

Also worth recording from the study, not ported here: claude-code injects the listing as a
per-turn system-reminder rather than static prompt text, and emits a second reminder ("New
skills discovered in <dir>, now available via the Skill tool") when a skill appears
mid-session. That second one is the install-then-use-immediately flow, and is a candidate
follow-up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(skills): lint routing metadata at authoring and learned-skill write time

Epic #6941 criterion 7. This is the one piece of the closed #6937 worth keeping, and
model-decided selection made it MORE relevant rather than less: with the scorer out of the
decision, the description is the model's only signal in the listing, so a generic or
over-long one degrades routing for every later request.

`lint_skill_routing_metadata` flags generic keywords, generic tags, a description past the
250-char listing cap, and a keyword that also appears in `exclude_keywords`. It gates both
learned-skill authoring paths (`parse_distillation`, `parse_refinement` both funnel through
`parse_skill_md`), so a model cannot write a skill that poisons the catalog.

Deliberately no pattern rule. An earlier draft flagged unanchored wildcards and failed 25 of
32 catalog skills; narrowing it to "ends in an open wildcard" still mis-flagged 3 of 4.
Wildcard position is not what makes a pattern promiscuous -- required literal specificity is
-- and one measured bad pattern is not enough evidence for a heuristic with that false-positive
rate. A lint people switch off protects nothing.

The checked-in catalog now passes it (`the_checked_in_catalog_passes_the_routing_metadata_lint`).
Six of 32 skills failed before the fix: five with descriptions of 269-338 chars, which the
listing was silently truncating, and `coding` with ten generic keywords (`code`, `fix`, `file`,
`test`, `build`, `error`, `change`, `delete`, `add`, `update`).

Fixing `coding` moved the reviewed routing baseline (#6595) on 7 of 8 cases, and the pattern is
the whole argument for the lint -- it was being selected for:

    security-audit, qa-test-plan, track-github-repository, commit-staged-changes,
    park-product-idea, local-web-ui-validation

and it drops out of every one. On `single-pr-code-review` it survives but demotes from 3rd to
5th. Baselines updated, which is what that test asks for when the change is intentional.

One honest scoping note: the keyword half of this is INERT at runtime under `ExplicitOnly`,
since the scorer no longer selects anything -- it matters for any future keyword-consuming path
and as catalog hygiene. The half that changes behaviour today is the description-length rule:
those five over-long descriptions were being truncated in the model-visible listing, which is
exactly the signal the model routes on.

cargo test: 945 passing across ironclaw_skills, ironclaw_first_party_extension_ports,
ironclaw_loop_host and ironclaw_architecture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): raise the active-skill cap from 4 to 8, it made correct routing impossible

`DEFAULT_MAX_ACTIVE_SKILLS = 4` was not a budget, it was a ceiling on correctness. On the
SkillsBench routing set, **7 of 31 tasks expect 4 or more skills and 3 expect 5**, so a
perfectly-routing agent could not satisfy them: recall was bounded by this constant rather than
by anything the model did. A benchmark measured against a limit the harness imposes on itself
reports nothing about the system under test.

Raised to 8. The real guard on skill context is `max_context_tokens`, which bounds how much
body text loads regardless of how many skills get named -- a large skill still consumes that
budget and pushes the effective count back down on its own. This constant only stops a model
from naming an unbounded list, which 8 still does.

Also updates every place that hardcoded "four": the tool description, the `names` schema
description and its `maxItems`, and the listing header. Those had to move together, since a
model told "at most four" while the selector allows eight will leave skills on the table.

One test assertion needed rewriting rather than retargeting:
`standalone_skill_activate_tool_loads_selected_skill_context` pinned the old description's
exact phrases. Its intent -- the description must tell the model WHEN to use the capability,
and must not imply every visible bare name is actionable -- is preserved and extended with a
third assertion on the clause that actually moved the metric ("instead of your own default
approach"), because that is the sentence that took activation from 0% to 40%.

1782 tests green across ironclaw_skills, ironclaw_first_party_extension_ports,
ironclaw_loop_host and ironclaw_reborn_composition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): ask for the complete skill set, not the smallest one

The tool description and the listing header both said "pick the smallest relevant set". That
is a minimisation instruction, and the measurements say it has no justification and a real
cost.

**Nothing to justify it:** precision over activated skills is **100%** in the ported build --
zero wrong activations across the measured tasks. There is no over-activation problem for a
minimisation instruction to prevent.

**A real cost:** recall over the expected set is **26.7%**, and the failure is under-activation
rather than mis-activation. On `powerlifting_coef_calc` the model activated `powerlifting` and
stopped, ignoring the other two skills the task needed -- 1 of 3. Of the tasks that activated
anything, half activated fewer skills than the task required. The prompt told it to do that.

So the guidance is inverted: activate every skill the task needs, name them together in one
call, and note explicitly that a task often needs several (a file format, a domain method and a
reporting step are three different skills). Precision is protected by a different clause that
stays untouched -- "do not activate skills that are unrelated to the task" -- which is the one
actually doing that work.

`standalone_skill_activate_tool_loads_selected_skill_context` gains an assertion pinning the
completeness instruction, since silently reverting to "smallest set" would regress recall with
no failing test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "fix(skills): ask for the complete skill set, not the smallest one"

This reverts 9eec1af53. I proposed it, predicted recall would rise and precision would hold,
and measured the opposite. Task-matched over 9 tasks at 227 candidates:

| metric                 | with "smallest set" | with "complete set" |
|------------------------|---------------------|---------------------|
| >=1 correct            | 44.4%               | 33.3%               |
| recall@R               | 22.8%               | 10.9%               |
| precision@R            | 100.0%              | 80.0%               |
| tasks w/ a wrong skill | 0                   | 1                   |

Worse on every metric, including the one it was meant to fix.

The per-task traces say why, and it is not the mechanism I assumed. On `exoplanet_period` the
original activated 3 of the 5 needed skills; with the change it activated **nothing**. On
`court_form_filling` the change activated one skill and it was the wrong one. So asking for
completeness did not make the model add the skills it was missing -- it made selection less
decisive overall, trading a confident partial answer for a guess or for paralysis.

My reasoning was that "pick the smallest relevant set" was unjustified because precision was
already 100%, so there was nothing for a minimisation instruction to protect against. That
inverted cause and effect: precision was 100% BECAUSE of the minimisation instruction, not
independently of it. I attributed the guard entirely to "do not activate skills unrelated to
the task", and the measurement says the two clauses were doing that work together.

Under-activation on multi-skill tasks is real -- 14.0% set completeness, and claude-code is no
better at 14.8% -- but it is not fixable by asking harder in the prompt. It needs a different
mechanism, and it belongs in its own issue with its own evidence rather than a prompt tweak
that makes three metrics worse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): measure what the HOST picks — the wrong-skill failure, 0 of 83 correct

The benchmark could not see the defect this work is about. Two things activated a skill before
this change:

  1. the model asks, via `builtin.skill_activate` -- a trace entry
  2. the HOST decides, scoring each skill's declared `activation:` keywords against the user's
     message during prompt assembly, and injecting the winners -- NO trace entry

#5417 is (2). The benchmark's scorer reads `skill_activate` calls, so it measured only (1), and
the pre-change arm therefore scored 100% "precision" while the host picked freely. That was an
invisible failure mode being reported as clean routing, and I reported it that way.

Host selection is `prefilter_skills_with_options`: a pure deterministic function of (message,
skill metadata). No LLM required, so this measures it exactly, in ~4 seconds, over every task's
real prompt against the real 227-skill catalog:

    27 tasks | 83 skills picked | 0 correct | 83 WRONG
    precision over host picks     0.0%
    tasks with >=1 wrong pick     27/27 (100%)

    wrongly picked most often:  routine-advisor (26 tasks), llm-council (12),
                                commitment-triage (10), coding (5)

`earthquake_plate`, a geospatial task, gets `routine-advisor`, `new-project`, `commit`,
`llm-council`. `citation_check` gets `security-review`, `review-readiness`, `llm-council`.

It is 0% rather than merely poor for a structural reason: the only skills carrying `activation:`
metadata are ironclaw's own bundled 32, and **none of those is ever a benchmark task's expected
skill**. So every host pick is necessarily wrong, and it fills all four activation slots with
them, displacing the skills the task actually needed. Keyword scoring did not rank badly here --
it had nothing correct available to rank.

Against the model-decided path measured on the same catalog: 22 of 23 activations correct
(95.7%). That is the before/after on the metric this epic is named after, and it was hidden
because the two paths are observed through different channels.

Assertions are upper bounds rather than equalities, so catalog drift will not fail this
spuriously while a return to host-side picking still trips it.

Needs the benchmarks checkout for its corpus; skips cleanly when absent, and
`NEARAI_BENCH_ROOT` overrides the location.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): pin that a skill's scripts cannot execute under multi-tenant hosting

#6745 lets a skill ship `scripts/*.py`, which is what makes a learned skill reusable rather than
a prose description. @serrrfirat asked the right question on the epic: "What if it's a malicious
script and we run it on host and ggs."

The answer today is that a multi-tenant agent has nothing to run it WITH. `builtin.shell` is the
only process-port-backed builtin, and it is removed from the capability package outright when the
resolved process backend cannot execute, so the script sits inert in the skill store with no tool
able to invoke it.

That guarantee was a chain of three unasserted inferences -- `HostedMultiTenant` →
`RuntimeProfile::SecureDefault` → `ProcessBackendKind::None` → shell removed. Every link is
correct and none was pinned, so changing any one of them would silently enable script execution
for every tenant. This asserts the property directly.

The second test is the anti-vacuity half: execution-capable backends MUST still expose
`builtin.shell`. Without it the first assertion would pass just as happily if the capability were
renamed or dropped everywhere, proving nothing about the multi-tenant case.

`TenantSandbox` is deliberately asserted as execution-CAPABLE rather than blocked, because it is
@henrypark133's sandbox work: `crates/ironclaw_process_sandbox` is already a complete Docker
backend (`--cap-drop ALL`, `no-new-privileges`, `readonly_rootfs`, `--network none`, non-root
uid) with no non-test caller. When it is wired, multi-tenant execution becomes safe *because it
is sandboxed*, and the first assertion should be revisited rather than deleted. Until then
composition refuses a policy requesting `TenantSandbox` without a port
(`MissingTenantSandboxProcessPort`), so the unsafe combination cannot be configured.

Scope note, unchanged: execution is gated, INSTALLATION is not. A multi-tenant agent can still
write `scripts/*.py` into its own store -- inert, but present. Gating the install path needs a
profile flag plumbed through static schema resolution and is documented on the epic rather than
half-implemented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): reconcile the install-rejection test with #6745, which allows content + files

`builtin_skill_install_rejects_hidden_url_install_fields` asserted that a `content` + `files`
install is refused. That expectation came from `main`; #6745 deliberately changed the handler to
accept it, and the merge left the two disagreeing -- so the test failed for the right reason and
against the intended behaviour.

Rejecting `content` + `files` WAS the bug. An agent attaching `scripts/analyze.py` had its entire
install refused, which is why 0 of 27 agent-authored skills shipped a resource file while 18 of 31
human-curated ones do. A skill that cannot carry a script is a prose description, and reusing it
means re-deriving the method every time.

Removed that case; kept `source` and `source_url`. Those are set by the URL-fetch path to record
provenance, so accepting them on a direct install would let an agent label its own output as
fetched from a trusted URL. Renamed to `builtin_skill_install_rejects_forged_provenance_fields`,
which is what the test now checks, and the doc comment records why the `files` case was removed so
a future reader does not restore it as an oversight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): the descriptor lint must not refuse to write a skill the agent authored

The lint gated `parse_distillation` and `parse_refinement`, so a learned skill failing it was never
written. Run against the skills agents actually wrote during the self-creation runs:

    26 agent-authored skills
    19 would be REFUSED by the write gate
    15 have no description whatsoever

**Scope, stated precisely.** The lint sits only on the DISTILLATION path — ironclaw's automatic
skill-learning. It does NOT sit on `skill_install`/`skill_manage`, which is what the benchmark's
`_selfcreate` tasks use ("you MUST save a reusable skill"), so those 26 skills were written through
an unlinted path and the benchmark's self-creation arm would not have broken. What the 19-of-26
figure shows is what distillation WOULD refuse when handed output of the same shape as real
agent-authored skills. That is an inference about the product's automatic learning path, not a
demonstrated break of the measured arm, and I am not going to overstate it.

It matters anyway, because distillation is the mechanism by which the product learns skills without
being told to. Refusing 3 of every 4 of them for a missing description would quietly disable it.

The lint is now split by who pays for the defect.

**Blocking** — `lint_skill_routing_metadata_blocking`, rules about declared activation TERMS. A
generic keyword poisons routing for *other* skills: `coding` declares `file` and `change`, and on
the reviewed baseline corpus it was being selected for security audits, QA plans and commit
staging. That cost is paid by every later request, so refusing the write is proportionate. Only 4 of
26 authored skills declare keywords or tags at all, so this gates without blocking authoring while
still stopping a model that tries to declare `file`.

**Advisory** — `lint_skill_routing_metadata_advisory`, rules about the skill's own description.
Empty or over-long hurts that skill's discoverability and nothing else, and refusing the write hurts
it strictly more: the agent produced something that works and gets nothing. These now warn with the
skill name attached, and are recorded rather than enforced.

`lint_skill_routing_metadata` still returns both, so authoring-time UI and CI keep the full list;
only the write gate narrowed.

`agent_authored_skills_pass_the_lint.rs` pins it against the real corpus and skips cleanly when the
stores are absent. It reports the advisory count too, so the 19 real description problems stay
visible rather than silently tolerated — worth fixing in the authoring prompt, which is a different
change from refusing the write.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): drive the PRODUCTION composition, and draw the boundary of disk-seeded validation

Asked whether this work would hold in production. It is a fair question, and the answer turned out
to be "we do not know yet", with a specific reason.

This builds the real thing — `RebornCompositionProfile::Production` over libSQL under the hosted
multi-tenant policy (scoped-virtual filesystem, brokered secrets, network deny, ask-always
approvals, no process backend). No mounts, no env switches. It opens a conversation and completes a
skill-execution turn, so skills ARE wired on the production path.

But a skill written to `tenants/<t>/users/<u>/skills/` on the host filesystem activates **nothing**
there: production resolves the skill store through the scoped-virtual filesystem, not the host disk.
Measured, not inferred — explicit `$name` activation returned an empty activation set.

That is the storage contract rather than a bug, and it has a consequence worth stating plainly:
**every other validation of this work seeds skills on disk, so none of it exercises production's
storage path.** The benchmark mounts `system/skills`; the local-dev tests write files. Both are real
paths for their own profiles and neither is production's.

The same class of mistake has now appeared four times in this work, each time as an artifact of the
instrument rather than the system: a copy-out scanning disk while the store is libSQL; agent-authored
skills escaping to the host's `~/.claude/skills`; a benchmark scoring only what the model requested
while the host picked freely; and a phase-2 arm that swapped in 195 unrelated skills and called the
result routing. This test exists so the next reader does not make it a fifth.

Closing the gap needs a seam this crate does not expose: installing a skill into the production
scoped-virtual store from a test, i.e. driving `builtin.skill_install` through the production
capability port instead of writing bytes to a directory. That is infrastructure work with an owner
other than this PR, so it is recorded rather than approximated. A test that seeds disk and asserts
success would report production coverage it does not have, which is worse than no test.

The assertion is therefore the negative one, with a message telling a future reader what to do if it
ever flips: if a disk-seeded skill becomes visible, the storage contract changed and the disk-seeding
validation elsewhere finally covers production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): drive the PRODUCTION composition and seed its DB-backed virtual filesystem

Asked for a real production E2E rather than more local-dev validation. This is as far as it goes
without infrastructure I do not own, and the boundary is stated rather than papered over.

**Proven.** `RebornCompositionProfile::Production` over libSQL, under the hosted multi-tenant policy
(scoped-virtual filesystem, brokered secrets, network deny, ask-always approvals, no process
backend), builds, opens a conversation, and completes a skill-execution turn. Its DB-backed virtual
filesystem then ACCEPTS a skill write at a tenant/user-scoped path via
`LibSqlRootFilesystem::write_file`.

That write is the new part, and it took two corrections to get right. The first version wrote to the
host disk, which production does not read at all -- activation returned an empty set. The second
wrote to the virtual filesystem before building the runtime and failed with `no such table:
root_filesystem_entries`, because migrations run at build time. Both are recorded in the test so the
next reader does not repeat them.

**Not proven.** A skill written there is not DISCOVERED. Either the production bundle source scans a
different scoped root, or it enumerates once at build time and does not observe a later write. That
is answerable by whoever owns the production composition; trying paths until one passes would yield
a test proving only that I found a path.

The assertion is therefore the negative one, with instructions to invert it once discovery is wired
-- at which point this becomes the end-to-end production claim the epic wants. A failing expectation
a reader can act on beats a comment nobody reads.

**Why this matters more than it looks.** Every other validation in this work seeds skills on disk:
the benchmark mounts `system/skills`, the local-dev tests write files. Both are real paths for their
own profiles and neither is production's. So the routing and self-creation numbers describe local-dev
behaviour, and that limit belongs next to them rather than discovered later by someone else.

The same class of error has appeared five times here, always the instrument rather than the system: a
copy-out scanning disk while the store is libSQL; authored skills escaping to the host's
`~/.claude/skills`; a benchmark counting only model requests while the host picked freely; a phase-2
arm that swapped in 195 unrelated skills and called it routing; and now disk-seeding on a profile
that reads a database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): full production E2E — a skill in the DB-backed store is activatable, fresh and after restart

The production claim, verified rather than inferred. `RebornCompositionProfile::Production` over
libSQL under the hosted multi-tenant policy (scoped-virtual filesystem, brokered secrets, network
deny, ask-always approvals, no process backend). No mounts, no env switches, no local-dev.

Two tests:

* a skill written into production's virtual filesystem is activatable by name in the same session;
* and it is still activatable by a runtime BUILT AFTER it was written -- the realistic shape, since a
  tenant installs a skill in one session and uses it in a later one against the same database.

Reaching this took three corrections, all mine, and each is recorded in the test because each
produces a plausible-looking test that proves nothing:

1. **Seeded the host disk.** Production reads a scoped-virtual filesystem, so activation came back
   empty -- the skill was never in the store. Every other validation in this work seeds disk, which
   is why none of it spoke to production.
2. **Seeded before building.** Migrations create `root_filesystem_entries` at build time, so the
   write failed with `no such table`.
3. **Used `/tenants/<t>/users/<u>/skills`.** The real mount is
   `/projects/tenants/<t>/users/<u>/skills` (`scoped_skill_context_mount_view`). Without the
   `/projects` prefix the write lands where nothing scans, which is what made this look like an
   unanswerable infrastructure question rather than a wrong string.

The restart test is what isolated (3) from a build-time-caching explanation: it failed too, which
ruled out enumeration timing and left the path. Worth keeping for that reason alone.

Consequence for the rest of the work: the routing and self-creation numbers were measured on
local-dev, and this establishes that the mechanism they exercise is reachable on production with the
same activation contract. That is the gap this PR previously documented as open, now closed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): read skills from the DB-backed tree the writer uses — closes #7168

An agent installs a skill, gets `{"installed":true}`, sees it in `skill_list` for the rest of the
session, and it is then gone forever: absent from Settings → Skills, unactivatable in every later
conversation. Reproduced by hand on the WebUI, not in a benchmark.

The reader and the writer were pointed at different trees:

| side | function | `/skills` resolves to | backend |
|---|---|---|---|
| write (`skill_install`) | `production_skill_management_mount_view` | `/tenants/{t}/users/{u}/skills` | **database** |
| read (discovery)  | `scoped_skill_context_mount_view` | `/projects/tenants/{t}/users/{u}/skills` | **host disk** |

`mount_database_roots` routes `/tenants` to the database; `mount_host_disk_roots` routes `/projects`
to the host disk. So the install landed in the DB and discovery listed the disk. Nothing reports
this: the install returns success, `skill_list` reflects the writer's own view within the session,
and the failure surfaces only as "the model ignored its own skill" — which reads as a model-quality
problem.

Adds `production_skill_context_mount_view`, the read-side mirror of the write view, and wires it on
the production path. Skills now live entirely in the virtual filesystem, database-backed, which is
also the only coherent answer under hosted multi-tenancy: a host-disk path for tenant skill data is
wrong on its own terms when there is no host disk for a tenant.

`/system/skills` stays where the writer leaves it so bundled skills keep resolving. Moving that tree
into the database too is the remaining step to skills being wholly DB-backed, and it needs bundled
seeding to write into the database at boot rather than ship on disk.

Guarded by `production_skill_read_and_write_mounts_resolve_to_the_same_tree`, which resolves a probe
path through both views and fails naming the divergence. It is a mount comparison rather than an
install-then-list round trip on purpose: the two views are the entire bug surface, it runs in
milliseconds, and a round trip would report "skill not found" without saying why. A second assertion
pins the tree to `/tenants/`, so a future change cannot quietly move skills back onto disk.

The two existing production E2E tests failed on this commit until their seed paths moved from
`/projects/tenants/...` to `/tenants/...`, which is the evidence the read tree actually moved rather
than the assertion being tautological. Both now pass fresh and after restart.

Also drops a stale assertion requiring a `names` property on the `skill_activate` schema; that input
became a single `skill` string earlier in this PR.

cargo test: ironclaw_reborn_composition 554, ironclaw_skills 181, production_runtime_skills 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): one DB-backed tree for every skill mount, and never install a skill discovery skips

Skill mounts were three views over two trees. The agent's in-run skill port and
discovery both resolved `/skills` to `/projects/tenants/<t>/users/<u>/skills` on
the host disk; Settings -> Skills and the product capabilities resolved it to
`/tenants/...` in the database. So `skill_install` inside a turn wrote to the
disk, Settings listed the database, and the skill was gone for good: `installed:
true`, present in `skill_list` for the rest of that turn, absent from Settings,
unactivatable in every later conversation (nearai/ironclaw#7168).

Every skill mount now derives from a single `db_backed_skill_grants`, so a reader
cannot drift from a writer. `/system/skills` stays on the host disk, where
bundled seeding writes; the two disk-backed views are deleted rather than left
available to wire in again.

The first attempt at this fixed only the branch hosted multi-tenant Postgres
takes. Local-dev, local-storage production and hosted single-tenant supply their
own `workspace_filesystems`, so they kept the disk reader and stayed broken in
exactly the reported way. Both readers are now pinned against the writer by
separate tests.

Two consequences handled here:

- Skills that already sit on the host disk -- from the legacy backfill, or from
  any agent install before this change -- are imported into the database at boot.
  Copied, not moved, and a database entry always wins, so it is idempotent and a
  downgrade is not destructive. Without this an upgrade silently drops every
  skill the user had.

- A manifest with an empty `description:` is repaired instead of persisted.
  Discovery rejects such a bundle (`InvalidSkillBundle`) and only warns, so the
  skill was accepted and then skipped forever. Measured with a real model: asked
  to save a reusable skill, it wrote frontmatter carrying `name:` alone. The
  description is derived from the skill's own opening prose. Repaired rather than
  refused because `SkillManagementCapabilityError` carries no message -- a
  refusal reaches the model as "the tool input could not be encoded", naming
  neither the field nor the fix, so the authoring turn would just be lost.
  Synthesized frontmatter for plain-markdown installs had the same defect and now
  carries a description too.

Verified end to end on a live local-dev server with a real model: the agent
authored a skill mid-turn, it appeared in Settings -> Skills, survived a server
restart, and a fresh conversation activated it with zero `skipping skill bundle`
warnings.

Tests that seeded skills on the host disk were testing the tree the runtime no
longer reads; they now seed and assert against the database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): seed built-in skills in production, and make skill files readable

Two gaps found by driving the real product.

1. Hosted multi-tenant production shipped with ZERO built-in skills. The bundled
   seeder is only reachable from `bootstrap_standalone_host`, which the Postgres
   path does not run -- correctly, since it writes through a host-disk filesystem
   and a tenant there has no host disk. But `/system/skills` IS mounted on that
   path, to the database, and nothing ever wrote to it. Settings -> Skills read an
   empty root and said "No skills installed" while local-dev listed all 32, with
   nothing logged either way.

   Every helper in the seeder already took `&dyn RootFilesystem`; only the entry
   point was disk-bound. Extracted `ensure_bundled_reborn_skills_installed_in` and
   seeded through the production filesystem at boot. The marker, install lock, and
   stale-skill removal are unchanged, so it stays idempotent across boots and safe
   when several instances share one database.

   `create_dir_all` is now best-effort: it walks up to `/system`, which is not a
   known virtual root, so it can never succeed for a root that is itself a mount --
   and `RootFilesystem::create_dir_all` is documented as deprecated anyway, because
   the entry plane infers directories from path prefixes.

2. Skill files were unreachable from the ordinary filesystem tools. Skill mounts are
   granted to the skill capabilities only, so `read_file` saw nothing but
   `workspace`. Observed on a real production turn: the model installed a skill,
   tried to read it back to verify it, got "does not resolve inside an available
   scoped root (available roots: workspace)", burned a tool call, and fell back to
   `skill_activate`.

   A parity gap, not just a poor error: in Claude Code a SKILL.md is a file, so
   models are trained to read it, and skills reference sibling files
   (`references/*.md`, `scripts/*.py`) that progressive disclosure expects the agent
   to open on demand -- dead ends without a readable path. The run's filesystem view
   now includes the skill roots READ-ONLY; writes stay exclusive to
   `skill_install`/`skill_update`, which validate the manifest.

Also fixes two skill_learning refiner tests that were failing on this branch: the
fixture's `keywords: [file, count]` trips the blocking generic-keyword lint, so
refinement returned `UnusableRoutingMetadata` and the merge silently degraded to
`KeepExisting`.

Reporting `has_scripts`/`has_requirements` to the Skills page is NOT here -- it
belongs with #6745, which is what lets an agent author a skill containing a script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(skills): show that an agent-authored skill carries scripts

This PR is what lets an agent author a skill containing a script. The Skills page
could not show that it did: `skill_info` hardcoded `has_requirements: false` and
`has_scripts: false`, so a scripted skill was indistinguishable from a prose-only
one. The WebUI has rendered the chips since #6194 and the wire fields have existed
since #7002 -- only the server never populated them.

It was not just agent-authored skills. `portfolio`, a BUNDLED skill, ships four
Python scripts (`weekly_report.py`, `backtest_strategy.py`,
`concentration_warning.py`, `alert_if_health_below.py`) and has always displayed as
prose-only.

- `SkillSummary::has_scripts`, from one stat on the bundle's sibling `scripts`
  path. Absent is the common case and is not an error, so only a genuine backend
  failure is logged -- a skill listing must never fail because a skill has no
  scripts.
- The bundled-summary path reads it from the embedded bundle files, so `portfolio`
  reports correctly there too.
- `has_requirements` comes from `requires_skills`, which was already on the summary.

Verified on a live production server: 33 skills listed, `portfolio` the one
reporting `has_scripts`, six reporting `has_requirements`.

Note: skill scripts still cannot EXECUTE under hosted multi-tenant --
`HostedMultiTenant` + `SecureDefault` resolves to `ProcessBackendKind::None`, which
strips `builtin.shell`. That is deliberate pending the tenant sandbox. So the chip
tells a multi-tenant user their skill has scripts the agent can read but not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): e2e for skill self-creation across a restart and a second conversation

The loop #6941 item 4 is about cannot be covered hermetically: it needs a live
server, a real model, a restart, and two separate conversations. Every hermetic
guard around it passed while the product was broken, because each tested a layer in
isolation -- the mount views agreed in the shape the test constructed, the install
reported success, discovery listed the tree the test had seeded. The failure only
appears when the writer and reader are the real ones, chosen by the real
composition, with a restart in between so nothing in memory carries the result.

Phase A covers a prose skill: authored mid-turn, listed in Settings, stored in the
database and NOT on the host disk, surviving a restart, and activated by a new
conversation with no 'skipping skill bundle' warnings.

Phase B covers a skill carrying a runnable script (the #6745 feature): the bundle
holds scripts/*.py in the database, Settings reports has_scripts, and a new
conversation activates it.

B5 asserts the bundled script is actually EXECUTED, and is reported as a known gap
rather than a failure unless E2E_REQUIRE_SCRIPT_EXEC=1. builtin.shell spawns a host
process while the script exists only in the DB-backed virtual filesystem, so there is
no path to run: the agent tries 'ls -la skills/<name>/<script>.py || echo NOT FOUND'
and falls back to 'python3 -c <algorithm re-typed inline>'. Right answers, wrong
mechanism, and it defeats the argument for shipping a script at all. Flip the env var
when that is fixed and the gap becomes an assertion.

Runs on its own port and IRONCLAW_REBORN_HOME so it never disturbs a dev server.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): create the e2e log dir before redirecting into it

`nohup … > "$LOG_DIR/server-1.log"` fails silently when the directory does not
exist, so the server never launched and the log that would have explained it was
never created either -- the run reported "server failed to start" with an empty
log, which reads like a product failure and was not one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: remove an agent-written artifact committed by mistake

`tenants/reborn-cli/users/reborn-cli/convert_labs.py` is not source. An agent wrote
it into the repository working tree during a live WebUI test and `git add -A` swept
it into 2a63accb4.

It is also evidence of a real defect, now recorded on #6941: `builtin.shell` inherits
the SERVER PROCESS's working directory, so a relative path in an agent's shell command
lands wherever the operator launched `ironclaw serve` -- here, the checkout itself.
The same run also created `skills/egfr-ckdepi/` and a `tenants/<t>/users/<u>/skills/`
mirror at the repo root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): stop reporting a failure when zero bundles were skipped

`grep -c` exits 1 when the count is zero, so `|| echo 0` appended a second zero and
the compare against "0" failed -- the healthy case reported "FAIL A6 0". Same shape
of bug as the missing log directory: the test was wrong, not the product.

Also records why B5's execution check is best-effort: display-preview subtitles redact
paths, so `python3 <path>` cannot be attributed to the bundle from the preview alone.
The inline `python3 -c` fallback is the reliable signal, which makes a false negative
possible and a false positive not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shell): default a command's working directory to the workspace, not the server's cwd

`resolve_local_host_workdir` fell back to `std::env::current_dir()` when a caller named
no workdir, so a shell command with a relative path ran wherever the operator launched
`ironclaw serve`.

Observed on a live local-dev server started from an ironclaw checkout. An agent asked
to save a skill wrote into the REPOSITORY: `skills/egfr-ckdepi/{SKILL.md,scripts/}`, a
`tenants/<t>/users/<u>/skills/` mirror, and two loose `.py` files. One of them
(`convert_labs.py`, from an earlier turn) was then swept into a commit by `git add -A`
and had to be removed. In production the same behaviour puts agent writes in whatever
directory the service was started from.

It also made the agent's own tools disagree, which is the expensive part.
`write_file` resolves through the scoped virtual filesystem; the shell resolved
against this cwd. The same relative path therefore meant two different places: the
model wrote SKILL.md and scripts/egfr.py, could not see them from the shell, ran
`ls`/`glob`/`find /` (the last dying on the 10s shell cap) to work out why, concluded
from `ls skills/` -- the checkout's own bundled-skill sources -- that skills live at the
repo root, and rewrote everything there. Twelve of that turn's twenty-one tool calls
went on this, and its final verification checked the copy in the repo rather than the
installed bundle.

Now: the `/workspace` alias by name, any other registered alias next, and the process
cwd only when a caller registered no aliases at all -- the ambient-host case, which has
no workspace to resolve against. An explicitly named workdir is unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert "fix(shell): default a command's working directory to the workspace, not the server's cwd"

This reverts commit a81cba9de9.

* chore: remove a second agent-written artifact swept in by git add -A

`tenants/reborn-cli/users/reborn-cli/ckd_epi_egfr.py` was written into the repository
by an agent during the e2e run and picked up by `git add -A` in 0e58adff8, the same way
`convert_labs.py` was in 2a63accb4.

Not a product defect. Local-dev sets `workspace_root = std::env::current_dir()`
(runtime/mod.rs), so the agent's workspace IS the directory `ironclaw serve` was
launched from -- deliberately, so it works on the project in front of you. Launching it
from a checkout therefore puts agent writes in that checkout. The lesson is for the
operator, not the code: run the dev server from a scratch directory, and stage files
explicitly rather than with -A.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): run the e2e in a scratch workspace and prove the checkout is untouched

Local-dev resolves `workspace_root` from the SERVER'S CURRENT DIRECTORY
(`build_standalone_local_runtime_services_input`), so whatever directory `serve` starts
in becomes the agent's workspace. The test launched the server from the repository root,
which made the repository the workspace: agent writes landed among tracked files, and
two agent-written scripts were swept into commits by `git add -A` before being removed
again.

That is the operator's mistake, not the product's -- production runs the service in its
own directory. So the test now does too: `serve` is launched from a scratch workspace,
and two checks close the loop. One diffs `git status --porcelain` before and after and
fails if the checkout changed at all; the other prints what the agent actually wrote
into its workspace, which is the useful signal for whether a skill's script can ever be
run from there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): add a production-profile mode, where the whole filesystem is the database

The local-dev shape is not production, and the difference is the source of most of the
filesystem confusion in these traces:

  local-dev   /projects and /projects/workspace are HOST DISK, and the workspace root is
              the server's own cwd. builtin.shell exists (LocalHost backend), so the
              agent has two namespaces -- virtual aliases and host paths -- that can
              disagree.

  production  production_database_root_filesystem routes /tenants, /projects, /memory
              and /system/* to the DATABASE, so the workspace is DB-backed too and there
              is no host disk at all. HostedMultiTenant + SecureDefault resolves to
              ProcessBackendKind::None, which strips builtin.shell. One namespace,
              nothing to disagree with it.

E2E_PROFILE=production writes the storage/policy config the production build fails
closed without, recreates its database so 'still listed after a restart' cannot pass on
stale rows, and reads bundle contents back out of Postgres rather than libSQL.

B5 inverts in that mode: the question is not whether a skill's script executed -- it
cannot, by policy -- but whether the shell was correctly withheld. A shell call under
hosted multi-tenant would be a policy escape, which matters more than the script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): write the production config after recreating the home, not before

The production block wrote $HOME_DIR/config.toml and the run then did
`rm -rf "$HOME_DIR"`, deleting it -- so the build failed closed with 'profile=production
requires [storage] backend = "postgres"', which reads like a misconfiguration and was an
ordering bug in the fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(coding): answer list_dir "/" with the roots, and say why a read-only root refused

Two failures an agent hit on a production-profile server, both diagnostics rather than
missing capability, and both making the traces unreadable.

`list_dir "/"` failed with "path  is not under an available scoped root (available
roots: skills, system skills, tenant-shared skills, workspace)". The offending path
renders BLANK because the safe-summary encoder maps `/` to a space, so the message named
nothing. The agent was doing something reasonable -- asking what the filesystem contains
before writing to it -- and the roots it wanted were already being computed for that very
error. `/` now lists the mount aliases.

`apply_patch` on a skill file failed with only "the tool was denied filesystem access".
`/skills` is deliberately read-only for the filesystem tools -- writes go through
`skill_install`/`skill_update`, which validate the manifest discovery requires -- but
nothing said so, and the agent fell back to skill_remove + skill_install to edit its own
skill. The denial now names the path, lists the writable roots, and mentions the skill
tools ONLY for a skill root: an existing test caught the first version telling an agent to
use `skill_update` for a read-only workspace, which is nonsense.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): stop a skill instructing execution where the environment forbids it

A skill body that says "execute it with python3 scripts/egfr.py" is a trap under
ProcessBackendKind::None: there is no shell and no interpreter, so the instruction cannot
be followed. A model told to do something impossible does not stop -- it improvises.

Measured on a production-profile server. Asked to apply its own eGFR skill, the agent
activated it, read scripts/egfr.py three times, enumerated its tools and correctly
concluded it had no way to execute anything, hand-expanded Taylor series for ln/exp, and
then POSTed the patient's creatinine and age to api.mathjs.org -- three times, receiving
46.493475453297044 and 31.625515257780567, the numbers in its final answer. Correct
results, obtained by shipping clinical values to a third-party service from a tenant
runtime.

So when no process backend exists, a skill body mentioning execution now carries an
explicit note: the instruction cannot be followed here, apply the documented method
directly from the skill text, and do not call an external service to compute. The last
clause is the point, not decoration.

Narrow by design: only bodies that actually mention execution (scripts/, python3, bash,
...) get the note. Appending it everywhere would spend context and train the model to skim
past it.  defaults to true, so no existing shape changes.

Derived from the resolved policy in filesystem_skill_context_source, which means this
needs no edit when the tenant sandbox lands: the moment HostedMultiTenant resolves to
TenantSandbox instead of None, it flips to true and the note disappears.

docs/skills/multi_tenant_enablement.md records the full enablement path -- what already
works on multi-tenant, why execution is off, the flip points in order (this note, then
giving a bundle a path the sandbox can reach, then making the e2e's B5 a hard assertion
via E2E_REQUIRE_SCRIPT_EXEC=1, then re-deciding network egress), and the profile matrix.

The e2e gains a single-tenant mode, which is the shape most deployments run: Postgres
storage with the local-host runtime policy, so a real process backend and a host-disk
workspace. E2E_PROFILE is now local-dev | single-tenant | multi-tenant, with 'production'
kept as an alias for multi-tenant, and each shape recreates its own database.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(filesystem): report which virtual paths a host process can open

There were three authorities on where a virtual path lives: the composite's mount
table, DiskFilesystem's own mount_local table, and HostProcessPort's separate
workdir_aliases list built at composition from a different value. They disagreed
silently.

Under hosted single-tenant the file tools resolve /workspace to
<workspace_root>/tenants/<t>/users/<u> (per-caller scoping) while the shell's alias
resolves the same string to <workspace_root>. So an agent wrote scripts/egfr.py with
write_file, ran , and landed three
directories above its own file. Neither side errored -- the script simply was not
there, and the model gave up and re-typed the algorithm into .

 makes host reachability a property the filesystem
reports, from the same table that serves the data: DiskFilesystem answers through the
same joiner every read and write uses, database backends answer None by the trait
default, and the composite routes by longest prefix exactly as it does for reads.

 is the load-bearing case. A skill bundle lives in the database, so no host
process can ever open it, and a caller that asks now learns that instead of spawning
against a path that does not exist.

Also adds SkillBundleSource::list_skill_bundle_files (default empty, implemented for
the filesystem source with file-count and depth caps) -- a bundle's files could be read
one known path at a time but never enumerated, which is required to copy one somewhere
a process can reach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(skills): stage an activated skill's files where its own commands can run

A skill body says `python3 scripts/egfr.py`. That is only meaningful from the skill's
own directory, and the bundle lives in the database, so no host process could ever open
it. Agents did not fail cleanly: one ran `cd /workspace && python3 scripts/egfr.py`
against a path three directories from its own file, another invented a
`tenants/<t>/users/<u>/skills/...` tree in its workspace, and both ended by re-typing
the algorithm into `python3 -c` -- losing the exact thing a shipped script exists to
preserve.

On activation, a bundle's non-manifest files are now copied into `.skills/<name>/` in
the caller's workspace and the body is told the directory to run them from. SKILL.md is
not staged: it is already model context, and a second copy invites edits discovery never
reads.

The directory is DERIVED, not assumed. `/workspace` does not mean one thing: under
per-caller scoping the file tools resolve it to `<root>/tenants/<t>/users/<u>` while the
shell's alias -- registered once at composition, knowing nothing about callers --
resolves it to `<root>`. Assuming either spelling produces a path that works for one
tool and silently misses for the other, which is the bug being fixed. So the stager asks
the filesystem where the staged directory really is, asks where the shell's `/workspace`
really is, and expresses one relative to the other via `host_path_for`.

Gated on a writable workspace and on `process_execution_available`: hosted multi-tenant
has neither a shell nor a writable workspace today, so nothing is staged there and a
body promising execution keeps the "cannot execute processes" note. When the tenant
sandbox lands, staged files sit under the workspace the sandbox already binds, so the
path works there with no second mechanism.

Every failure degrades rather than propagates -- no stager, no execution backend, an
unreadable bundle, or a failed write all mean "no staged path", and the skill still
activates with its instructions intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): express the staged path against the shell's workspace, and name workdir

Two defects in the first staging attempt, both found by running it.

The derived path was measured against the FILE TOOLS' workspace root, so under per-caller
scoping the two per-caller segments cancelled and the note said `/workspace/.skills/<name>`
-- which the shell resolves to <root>/.skills/<name>, missing tenants/<t>/users/<u>
entirely. It is now measured against the root the shell's alias actually resolves to,
which composition owns.

And the note merely stated the directory. The model then ran the body's
`python3 scripts/egfr.py` with no working directory at all and missed the file, exactly as
before. The note now names the shell's `workdir` parameter and shows it in use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shell): scope /workspace to the caller, as the file tools already do

One alias named two directories in one process. `scoped_workspace_mount_view` resolves
`/workspace` to `<root>/tenants/<t>/users/<u>`, while `HostProcessPort`'s alias list --
built once at composition, before any caller exists -- resolved it to `<root>`.

So an agent wrote `scripts/egfr.py` through `write_file`, ran `python3 scripts/egfr.py`
in the shell, and landed three directories above its own file. Neither side errored. It
then re-typed the algorithm inline, and in another run invented a
`tenants/<t>/users/<u>/` tree by hand trying to reconcile the two.

The port already receives the caller's `ResourceScope` on every request, so it now
derives the same subtree per request rather than depending on a composition-time value it
cannot know. Only `/workspace` is narrowed; `/host` and the raw host-home aliases are
ambient by construction and have no per-caller subtree.

This affects every file an agent writes and then runs, not just skills.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): hermetic two-thread fixture -- author a scripted skill, then run it

Distilled from ten live demo runs against real models. Every one failed somewhere in
this sequence and no existing hermetic test caught any of them, because each tested a
layer while the failures lived between layers:

  1. skill_install reported success and the skill never appeared again -- writer and
     reader resolved /skills to different trees (#7168).
  2. A manifest with no `description:` installed fine and was skipped by discovery
     forever, with only a warn.
  3. Thread 2 activated the skill, read scripts/egfr.py, and could not execute it: the
     bundle lives in the database, so no host process can open it.
  4. Deprived of execution, one agent POSTed the patient's creatinine to api.mathjs.org
     to do the arithmetic.
  5. After staging landed, the path the model was TOLD still missed, because /workspace
     means <root>/tenants/<t>/users/<u> to the file tools and <root> to the shell.

So the fixture asserts the chain rather than a layer. It installs through the real
product capability, then TEARS DOWN AND REBUILDS the runtime over the same store -- that
is what makes it a later conversation, and a fixture holding one runtime open cannot see
#7168 at all. It then activates by name, locates the staged bundle, and runs
`python3 scripts/egfr.py` from the staged directory exactly as the skill body instructs.
The script prints a marker that re-derived arithmetic cannot produce, so "it ran" is not
inferred from a log line.

The staged bundle is found by SEARCH, not by assuming a path. That caught a real thing on
the first run: standalone uses the shared workspace policy, so there is no
tenants/<t>/users/<u> segment, and a fixture hardcoding either spelling would have tested
the spelling instead of the mechanism.

A second fixture covers the description case directly: a manifest carrying `name:` alone
must still activate in a later conversation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): advertise the plain workspace path, not a doubled one

The staged directory handed to the model was derived by measuring it against the shell's
workspace root and expressing one relative to the other. That was correct while the shell
and the file tools disagreed about `/workspace`, and became wrong the moment they were
unified: it emitted `/workspace/tenants/<t>/users/<u>/.skills/<name>`, which BOTH tools
then resolved beneath the per-caller root a second time.

The doubled directory does not exist, so every shell command failed with
`Failed to spawn command: No such file or directory (os error 2)` -- which reads like a
missing interpreter and is not -- and `list_dir` on the same advertised path failed too. An
agent following its own skill's instructions could run nothing, and went back to copying
files by hand.

Now simply `/workspace/.skills/<name>`, which is correct precisely because
`HostProcessPort` applies the same caller scoping the mount view applies.

Pinned where the string is produced, since the two-thread fixture cannot catch this class:
staging writes through the caller's own view, so the bytes land correctly even when the
advertised string is wrong. That limitation is now recorded in the fixture.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(coding): treat / as the workspace in every tool, not just list_dir

`scoped_path_input` mapped `""` and `.` to the workspace but passed `/` through, and
`ScopedPath::new("/")` rejects the bare root. The resulting summary rendered the offending
path BLANK, because the safe-summary encoder maps `/` to a space:

    path  is not under an available scoped root (available roots: skills, system skills,
    tenant-shared skills, workspace)

Agents hit it constantly -- a leading-wildcard glob, or looking at the root to see what
exists -- and were told nothing. `list_dir` was special-cased earlier; `glob`, `grep`,
`read_file`, `write_file` and `apply_patch` all still failed this way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(coding): fixture the blank-path failure and the write-then-read round trip

Two regressions from live demo traces, neither covered.

`/` failing with `path  is not under an available scoped root` -- the offending path
rendering blank because the safe-summary encoder maps `/` to a space. Now table-driven over
glob, grep and list_dir, so one tool cannot be fixed in isolation again while the others
keep failing (which is exactly what happened: list_dir was special-cased and the rest were
not).

And the write-then-read round trip on a relative path, which is the invariant an agent
depends on when it authors a script and then runs it -- the one that silently broke when
`/workspace` meant two different directories to different tools.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(skills): report where a skill is stored and where it can be run

Thread 1 of the demo spent most of its tool calls working out something the tools should
have told it. After installing a skill carrying a script it tried to execute the file from
`/skills/<name>` -- the read-only, database-backed store that no process can open -- then
`ls`'d around, hand-copied the script into its workspace, ran the copy, compared the two,
and closed by asserting that "the installed skill's script is directly executable from the
skill root". That is false in every deployment, and it said it because nothing said
otherwise.

So `skill_install` and `skill_list` now report both places: `store_path_read_only` and,
for a bundle carrying files, `runnable_path_after_activation`. Both tool descriptions state
the rule once -- the skill root is never executable; bundled files become runnable in the
workspace on activation.

Not staged at install time: the skill capability holds the skill-management mount view and
no workspace view, so staging there would mean writing outside the view that authorizes it.
Activation is one call away and already stages, so the agent needs the path, not an earlier
copy.

`runnable_skill_dir` is the one place the spelling lives, and it carries a note that
`bundle_staging` must agree with it -- if they drift, the symptom is an agent running a
command in a directory that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): make the install-then-use ordering explicit instead of discoverable

Reporting the runnable path was not enough. An agent installed a skill, took the path out
of the install result, and read it immediately -- before activating -- so it got the generic
"can't access your workspace file" twice, activated, and only then succeeded. Two wasted
calls and a confusing trace, for an ordering the tools knew and did not state.

Two changes, both at the point of the mistake:

`skill_install` now returns `bundled_files_runnable` as a structure -- `requires_first`
(skill_activate with this name), `then_at` (the path), and why -- rather than a field named
`..._after_activation` that reads as a label instead of a precondition.

And a miss under `.skills/<name>` now says so: "does not exist yet ... call skill_activate
with name=<name> first, then read or run it from there", in both read_file and list_dir. A
missing file anywhere else is still just a missing file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: clear the CI gates on the changed crates

`cargo clippy --all --tests -- -D warnings` and the panic checker are merge gates, and both
failed on this branch. All of it is mine.

- `activation.rs` tested `is_some_and(...)` and then `expect`ed the same Option, so the
  invariant lived in a comment rather than the types. Bound by pattern instead, which the
  production panic gate accepts.
- `bundle_staging` kept a `shell_workspace_root` field that went dead when the derived path
  was replaced by the plain workspace spelling; removed here, along with the parameter it
  was threaded through and the composition constant that fed it.
- Needless borrows, `sort_by` -> `sort_by_key`, `&[x.clone()]` -> `slice::from_ref`, a
  single-element `for` loop, an orphaned doc comment left behind by an earlier deletion, and
  four `pub` items in `ironclaw_threads::contract` that nothing outside the crate uses.
- Constant-valued `assert!`s moved into `const {}` blocks.

Also verified: composition mass budget (6.48% of 23.98% ceiling), hermetic env guard,
include_str/Docker COPY coverage, and no tracked files matching .gitignore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): pin the direct-install input contract at the capability boundary

Four cases covering what a caller may and may not put in a `builtin.skill_install`
input, asserted through runtime dispatch rather than against whichever helper
currently normalizes the input.

The normalizer has already moved once (host runtime -> ironclaw_extension_support,
WS3) and is about to be merged across that move again. Written so the same four
pass on both sides: a resolution that quietly re-tightens the inline arm fails the
first one instead of silently dropping the capability this PR adds.

The dividing line these pin is provenance, not shape:
- `content` + `files` installs, and the script lands on disk verbatim
- `bytes_base64` works on the direct arm too, not only the rewritten URL payload
- `content` + `files` + `source`/`source_url` is still refused whole
- a `../..` bundle path is refused and writes nothing outside the skill directory

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): repair the post-merge build and close the review findings

Build breakage from the merge: main added two `SkillSummary` construction sites
(`skill_learning`, `lifecycle_product_service`) that this branch's new
`has_scripts` field left incomplete. Both are fixtures whose assertions do not
depend on it, so both set `false` with a note saying why.

CI, all under `-D warnings`:
- Four `result_read` cap items in `ironclaw_threads::contract` were `pub` in a
  private module (`unreachable_pub`). Nothing outside the crate reads them, so
  they are `pub(crate)`.
- Four constant-value asserts (two in the same contract module, two in
  `activation_strategy`) move into `const {}` blocks, which is what they always
  meant: they are compile-time invariants, not runtime checks.

Review findings:
- The stale-default doc note (coderabbit, ironloop) was against `ecabcb5fe`, before
  `4951d76bb` reverted the flip. Docs and `DEFAULT_SKILL_INJECTION_MODE` both say
  `Listing` with `full` as the opt-in, so there is nothing left to correct.
- The unset-env branch is now reachable from a test (coderabbit).
  `skill_injection_mode_from_env_value` takes the lookup's `Result`, so the product
  default can be asserted without `remove_var` racing every other test in this
  binary. Covered along with `full`, trimming/case, empty, unrecognized, and
  non-unicode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): gate criteria activation on requirements, and address the review

The Major finding first, because it is a real hole: `unmet_requirements_refusal`
was wired into the explicit-mention loop and `select_named_skill_activations` but
NOT into the criteria (keyword/regex) loop, so a skill declaring an unmet
`requires.bins`/`env`/`config` still auto-activated "cleanly". That is the worse
half of the two: a criteria selection is the one the user never asked for by name,
so nothing at all connects the later shell failure back to the missing binary.
Same gate, same message, now on both paths.

Also from review:
- The refusal message rendered `SkillTrust` with `{:?}`. It goes to the model, so
  it renders through `Display` (`installed`/`trusted`) -- which is also the
  spelling `ironclaw_skills` documents as the one that gates content exposure.
  Two tests asserting the debug spelling move with it.
- The truncation warning fired on every prompt-context build. A catalog over the
  budget stays over it, so that repeated one line for the life of the process.
  Now warned when the hidden count CHANGES, which is the only new information;
  the model-visible hidden-count message stays unconditional.
- Corrected a comment claiming ExplicitAndCriteria is the default (it is
  ExplicitOnly; `criteria_config()` opts in).

And the caller-level coverage the reviewer asked for, which
`crates/ironclaw_skills/AGENTS.md` now also requires for changes to skill-content
exposure: three cases driven through `SkillActivationHandler::invoke` with a
capturing result writer, asserting the PERSISTED payload -- clean activation,
trust refusal, unknown name. The builder-level tests could not see the defect
this contract exists to fix, which was a payload built correctly and then not
delivered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* style: rustfmt the skill-reachability files

`cargo fmt --all --check` was red on five files this branch touches. Formatting
only; no behavior, no reordering beyond rustfmt's own import sort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skills): state accurately why the inline arm now accepts a bundle

The comment claimed a review decision had been "reversed on evidence", which
overstates what happened and would read as overriding the team.

What actually happened: the refusal predates #7141 entirely -- it lived in the
host-runtime copy of this resolver, and #7141 carried it across the move to this
crate verbatim, declining a reviewer's suggestion to relax it there. That was the
right call for a move-only refactor. This PR is where the behavior change belongs,
and it is made deliberately with the measurement attached.

Comment only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): pin the criteria-path requirement gate, fix two lints

The gate added in the previous commit changed production behavior with zero test
movement, which is the signal that nothing covered it. Confirmed by removing it
again: the new case fails at the body-disclosure assertion, i.e. the skill with the
absent binary really did activate and its prompt really did reach the model.

Asserted through `set_activation_observer` rather than a return value, because that
is where the criteria path's feedback actually goes -- it is the seam the live
projection consumes, so a refusal invisible there is invisible in the product.

Two lints under `-D warnings`, both pre-existing on this branch:
- `set_activation_observer` returns a `Result` that the new test dropped.
- An orphaned doc comment for `criteria_config()` sat above
  `assert_no_skill_body_disclosed`, documenting the wrong function. Moved onto the
  function it describes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): clear this branch's CI gates and one stale assertion

A test breakage that predates the merge: `standalone_skill_activate_tool_loads_
selected_skill_context` required the `skill_activate` schema to advertise a `names`
property, which directly contradicts the `skill`-is-a-string assertion two lines
above it. The schema was narrowed to one skill per call on this branch and this
assertion was not updated. Inverted to pin the actual decision -- a legacy `names`
array is still ACCEPTED by `parse_skill_activate_names` so an in-flight caller does
not hard-fail, but advertising it is what invited the multi-skill calls that
produced every wrong activation in the 29-run measurement.

Gates:
- `check_no_panics.py`: `plan.expect("checked above")` after an `is_some_and` guard
  is now bound by pattern. Equivalent today; only the pattern form stays correct if
  the condition is edited, which is why the gate flags the other.
- clippy `-D warnings`: a one-element `for` loop in
  `multi_tenant_skill_scripts_cannot_execute` (named as one backend instead, so a
  second non-executing backend needs its own case and message rather than a silent
  extra iteration), and two `sort_by` comparators that are `sort_by_key`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): address the review — one blocking path bug, one broken invariant

**Blocking (ironloop, High).** `/tenant-shared/skills` resolved to
`/tenants/<t>/tenant-shared/skills`, repeating the alias inside the target. Tenant-shared
state lives under `/tenants/<t>/shared` — that is what `invocation_mount_view` resolves
the alias to, and both siblings (`reborn-projects`, `reborn-identity`) follow it. Nothing
writes or migrates the misspelled subtree, so a tenant that HAD shared skills would
silently stop discovering them. Fixed, and pinned by
`tenant_shared_skills_resolve_under_the_canonical_shared_subtree`, which asserts against
the sibling layout rather than a hardcoded string.

**The PR's own invariant was half true (2 findings, Medium).** "Every skill mount view
derives from one DB-backed tree" — except a disk-backed `skill_management_mount_view()`
survived and fed `PolicyApprovalLeaseTermsProvider`, in production, not just tests. So the
lease terms a user approved for `skill_install` named `/projects/skills` while the install
wrote to `/tenants/<t>/users/<u>/skills`. It is now derived per gate from the gate's own
scope (`skill_mounts_for`), exactly as workspace mounts already were, and the disk-backed
view is deleted along with the runtime field and test accessor that carried it. The
`skill_mounts_for_test` helper became a scope-taking free function, so the harness asserts
the same tree production mints.

Real bugs:
- `insert_frontmatter_description` took `lines().next()` as the `---` delimiter, but the
  parser tolerates leading blank lines. A skill starting with one had `description:`
  inserted above the delimiter, turning a repairable install into a failed parse.
- `import_host_disk_skills_into_database` ran every boot and the disk copy is deliberately
  left behind, so a skill the user REMOVED came back at the next restart. Now gated on a
  one-shot marker under `/system/settings`: a migration, not a standing sync.
- `is_skill_alias` matched every `*skills` alias, so a denied write to `/system/skills` or
  `/tenant-shared/skills` was told to use `skill_install`, which cannot write either.
- `.skills` is now actually in `DEFAULT_EXCLUDED_DIRS`, which the staging module doc
  already claimed. Staged copies no longer show up in a workspace glob/grep.
- Staged bundles are cached by caller + bundle + content hash. `body_context` runs on every
  turn's activation path and was re-walking, re-reading and re-writing an unchanged bundle
  each time.
- `host_path_for` is removed from `RootFilesystem` and both `ScopedFilesystem` wrappers. It
  had no production consumer: staging writes through the filesystem, so the reachability
  probe it was added for is not needed. Its contract test goes with it.

Docs that contradicted the code: the `/system/skills` note claimed host-disk-only while
this PR seeds it into Postgres for multi-tenant (now describes both, and says the composite
decides); `ensure_manifest_description` carried two stacked doc blocks, an earlier
"refuse at the write" draft above the "repair, don't refuse" one that matches the code.

`.gitignore` now covers `/tenants/`, the local-dev agent workspace — an agent-written
script from a demo session is how the stray file in this PR's first comparison got there.

Also: one arch-gate false positive from my own prose ("no notion of enumeration" tripped
the concrete-extension check — reworded rather than allowlisted, keeping the register at
zero), and two ratchet baselines LOWERED for the debt this PR deletes (frozen count 51->50,
aggregate members 276->275), which #7147's own doc comment requires in the deleting PR.

Verified: workspace `cargo check --all-targets` and `clippy -D warnings` clean; full
`ironclaw_architecture` suite 37/37; reborn_composition lib 508 + integration suites green;
skills/filesystem/extension_support/first_party_extension_ports/host_runtime/extension_host
suites green; panic gate, hermetic-env, include-str and composition-budget gates OK.

Not done, deliberately, and left for a follow-up: the seven "add a test for X" findings
(bundle-enumeration bounds, staged_path traversal, runnable_dir literal duplication,
NO_PROCESS_EXECUTION_NOTE end to end, the advertised install/list fields,
derive_install_description escaping, update_skill blank-description repair) and the
has_scripts one-stat-per-skill listing cost, which this PR's plan already recorded as
separate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: raise the composition mass ceiling for this layer's assembly wiring

CI's "Check composition mass budget" step reds this branch by 62 LOC. Not because this
branch is large: main sits only 54 LOC under the effective ceiling (40,499 + 150 tolerance
against 40,595 observed), so the gate currently trips on any PR adding more than that to
composition, and this one adds skill-summary and product-surface assembly.

Raised to the measured 40,711 in both places the gate pairs — `[gate].loc_ceiling` in the
manifest and `COMPOSITION_ABSOLUTE_SRC_LOC` in `reborn_restructure_baselines.rs`, since a
second ratchet fails when they disagree, which is how it enforces recording the change in the
PR that causes it. Measured with `check-composition-budget.sh --print`, set to current rather
than padded, per the manifest's own protocol.

A raise is a reviewed decision by that file's rules, not routine wiring, so it is flagged
here and in the PR body rather than left in a diff. The next wave close should re-ratchet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: correct the composition ceiling to the clean measured count

41,414 was measured on a working tree a bad `git stash pop` had polluted. The clean count is
41,096, and the 318-LOC difference read as unclaimed headroom — which the gate's own C11
self-test refuses, since a ceiling that far above the live count is the inert-ratchet failure
the absolute bound exists to prevent. Corrected in both paired places, with the wrong figure
and its cause recorded in the manifest rather than quietly overwritten.

Gate green, 76/76 self-tests pass, both ratchets green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skills): cut 346 lines of comment prose, and three duplicated blocks with it

The narrative belongs in commit messages, where it already is. Kept the one-liners that stop a
regression and the measurements that justify a constant; dropped the retellings.

Three of the deletions were duplicated blocks, not verbosity: an earlier draft left stacked above
its own replacement above `SKILL_LISTING_*` and `DEFAULT_SKILL_ACTIVATION`, and a copy-paste of the
criteria-gate comment repeated verbatim. Each read as two competing explanations of one thing.

Comment share of this stack's diff: 29% -> 25%, 1,970 -> 1,624 lines. Composition production LOC
41,096 -> 40,995, so `[gate].loc_ceiling` and its paired `COMPOSITION_ABSOLUTE_SRC_LOC` come DOWN
by 101 rather than being inherited; the manifest note records both earlier figures and why each
moved.

No behavior touched: the only non-comment deletions in this diff are two assertion strings replaced
with shorter ones, plus the two paired budget numbers.

Verified: both threads of the demo fixture pass (`thread_one_authors_a_scripted_skill_and_thread_
two_executes_it` and `a_skill_installed_without_a_description_is_still_discoverable`, 2.26s),
`fmt --check` clean, workspace `clippy -D warnings` clean, 49 test binaries green across skills /
first_party_extension_ports / extension_support / architecture, and the budget (incl. its 76
self-tests), panic and hermetic-env gates pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(skills): drive the demo's second thread through a mocked model and the real shell

The existing two-thread fixture proves the staged bytes land somewhere runnable, and says in its own
comment that it "cannot catch a wrong ADVERTISED path" -- it finds the script by walking the
workspace and runs it with `std::process::Command`. That is the half the demo actually failed on:
the string handed to the model was `/workspace/tenants/<t>/users/<u>/.skills/<name>`, the shell and
the file tools resolved it a second time beneath the per-caller root, and every command died with
`Failed to spawn command: No such file or directory`.

This closes it. Thread 1 installs the skill with its script; thread 2 is a fresh runtime over the
same store, and `SkillShellGateway` -- a mocked model -- does what the real one does: reads the
activated body, PARSES the workdir out of the staged-files note rather than reconstructing it, and
calls the real `builtin.shell` capability with that workdir. Everything between the model and the
file is the production path: activation, staging, the mount views, the process port.

Verified by reintroducing the bug rather than trusting a green run. With `runnable_dir` emitting the
old per-caller spelling again, this fails with

    left:  "/workspace/tenants/two-thread-tenant/users/two-thread-owner/.skills/egfr-calc"
    right: "/workspace/.skills/egfr-calc"

and passes once the fix is restored. All three skill fixtures green in 2.67s; the file sits under
`src/runtime/tests/`, so the composition mass count and its ceiling are unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): narrow STAGED_SKILLS_DIRNAME after the crate move

CI's clippy runs `--all-features`, which I had not; it flags what the plain run did not. After
`bundle_staging` moved into `ironclaw_loop_host`, the constant was re-exported from the module but
not from `lib.rs`, leaving it both unreachable-pub and unused. Nothing outside this crate reads it —
the only other mention is a comment in the coding tools' config — so it is `pub(crate)` and off the
module's export list.

All three demo fixtures still pass (2.82s), `--all-features` clippy clean, fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): bound the listing budget by the snippet cap it ships inside

Two review findings, both real, both from the same blind spot: a constant was checked against what
looked reasonable rather than against the limit downstream actually enforces.

**The listing could take the runtime down (serrrfirat, High).** `LISTING_CHAR_BUDGET` was
`512 * (250 + 64)` = 160,768 chars, and the listing ships as ONE model-visible snippet.
`skill_context.rs` rejects a snippet over `LOOP_CONTEXT_SNIPPET_MODEL_CONTENT_MAX_BYTES` (65,536)
with `ContextBudgetExceeded`, which is a hard error that fails the whole skill-context build — not a
truncation. So past roughly 209 full-length entries a large catalog did not list fewer skills, it
failed. The budget is now derived FROM that cap with headroom for the header and hidden-count note,
and a `const` assert ties the two together so the old value cannot come back: restoring it fails the
build outright with `evaluation panicked: the rendered listing must fit the single snippet it ships
as`. A test asserts the rendered listing in BYTES (512 entries of multibyte descriptions, the worst
case the enumeration cap allows) rather than in chars against the budget, because the cap is a byte
cap.

**`ActivationStrategy::Disabled` was inert (coderabbit, Major).** `criteria_enabled()` had no
production caller at all — only its own unit test — so binding `Disabled` still ran keyword
activation, exactly like `CriteriaOnly`. It is now the third gate on the criteria path, next to the
global auto-activate switch and the selection mode.

Verified: 635 + 54 + 4 tests green across loop_host, `--all-features` clippy clean (which is what CI
runs), fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(threads): make the result_read env knob actually raise the cap

serrrfirat, High: `IRONCLAW_TOOL_RESULT_READ_MAX_BYTES` was inert. It widened
`validate_tool_result_record_read`, which sits DOWNSTREAM, while the caller-facing gate in
`result_read.rs` stayed pinned to the compile-time `TOOL_RESULT_RECORD_READ_MAX_BYTES` (24 KiB) and
the advertised schema still said `maximum: 24576`. A larger read was rejected before it could reach
the widened validator, so setting the variable changed nothing.

The gate and the schema now resolve `effective_tool_result_read_max_bytes()` per request.

This also corrects a fix I made earlier in this PR for the wrong reason. Clippy flagged
`effective_tool_result_read_max_bytes` as `unreachable_pub` and I narrowed it to `pub(crate)` — but
it had no cross-crate caller precisely BECAUSE the wiring was missing. The lint was reporting the
bug, not dead code. It is `pub` again, with the caller it was always supposed to have.

Tested as a wiring identity (gate == effective cap, schema == gate) rather than by setting the env
var: these tests run in-process and in parallel, so mutating process environment races every other
test reading it, and the identity is exactly what regressed.

Verified: workspace `cargo check --all-targets` clean, 12 test binaries green across loop_host and
threads, fmt clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(threads): satisfy the production-target lint lane

CI's PR lane lints the DEFAULT target set (lib + bins, no tests or examples) with
\`--all-features\`; I had been running \`--all --tests --examples\`, and the extra targets masked
both of these.

- \`TOOL_RESULT_READ_ENV_CEILING_BYTES\` was widened to \`pub\` alongside
  \`effective_tool_result_read_max_bytes\` in the previous commit, but only the function is
  re-exported from \`lib.rs\`, so the constant was unreachable-pub. Only that function reads it, so
  it is \`pub(crate)\`.
- \`result_read.rs\` no longer reads \`TOOL_RESULT_RECORD_READ_MAX_BYTES\` now that the gate resolves
  the effective cap, so the import goes.

Verified with the lane CI actually runs (\`cargo clippy --workspace --all-features -- -D warnings\`,
no test targets), plus fmt and the loop_host/threads suites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(skills): record the listing-budget const assert for the panic gate

\`check_no_panics.py\` flags the \`assert!\` in the new \`const _: () = ...\` block. It cannot panic at
runtime — rustc evaluates it, so restoring the old budget fails the BUILD — but the scanner reads
the macro, not the const context.

Suppressed with the inline \`// safety:\` rationale the tool documents, placed INSIDE the call's span
where the scanner looks for it, rather than parked in the reviewed-invariant baseline: the baseline
is for real runtime panics that were audited, and this is not one. (I tried the baseline first; it
then correctly reported the entry as stale once the inline marker took effect.)

All three panic checks pass: diff-scoped, reborn baseline (50 invariants, unchanged), and self-test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skills): cut the comment bloat on this layer

Comment share of this PR's diff: 33% -> 26%, 471 -> 330 lines. The measurements that justify a
constant stay; the retellings of how we got there go, since they are already in the commit history.

One of these was a duplicate rather than verbosity: `DEFAULT_SKILL_ACTIVATION` carried an earlier
draft stacked directly above its own replacement, so the file gave two competing accounts of the same
default. I had removed that copy at the top of the stack only; removing it here means all three
layers carry one version instead of conflicting on every merge.

Also corrects a doc that contradicted the code, which Copilot flagged:
`effective_tool_result_read_max_bytes` was documented as clamping to
`TOOL_RESULT_RECORD_READ_MAX_BYTES` when it clamps to `TOOL_RESULT_READ_ENV_CEILING_BYTES` — the
whole point of the separate ceiling.

Comments only; no code touched. `--all-features` clippy on the production target set (the lane CI
runs) clean, fmt clean, 19 test binaries green across skills / threads / loop_host /
extension_support.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(skills): cut the comment bloat on this layer

Same pass as the layer below. The blocks that went were retellings: how the requirement-gating
module came to be deleted and restored, three paragraphs on why the listing budget is the size it
is, the full derivation of the active-skill cap. What stays is the measurement that justifies each
number, because deleting those makes the constants look arbitrary.

`gating.rs` lost the most (-24): its module doc explained the delete-and-restore history at length,
which belongs in #6943's trail, not at the top of the file.

Comments only. Production-target `--all-features` clippy clean, fmt clean, panic gate clean, 13 test
binaries green across skills and loop_host.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: resolve conflicts with main and collapse the diff to this layer

Main had advanced past #6938's merge, so the branch was carrying its own
now-merged copy of the middle layer and diffing that against main. Merging
current main drops it: 77 files/+7559 -> 44 files/+3152.

Where a conflict was comment prose only, main's wording wins -- re-trimming
text that already merged is churn this PR should not carry. Ours is kept only
where the code differs: the bundle-staging body_context calls, the
process_execution_available plumbing, and skills::gating, which main never
received.

approval/tests.rs takes main's file-split, with the skill-mounts argument
dropped at its 7 call sites since skill_mounts_for now derives it per gate.
docs/skills/agent_authored_bundles.md was resurrected by the merge and is
removed; multi_tenant_enablement.md moves under docs/internal/skills/ for the
publication boundary gate. Composition ratchet re-recorded 40_747 -> 41_149,
ceiling and arch-test constant together.

* test(arch): ratchet the struct-debt member baseline down to 274

skill_mounts_for derives the skill mount view from the gate, so the member
that carried it is production-used rather than test-support-only. The gate
requires the baseline to drop in the PR that removes the debt, otherwise the
1-member gap is untracked slack a later change could spend silently.

* test(composition): seed production skills through the production namespace

The merge took main's copy of these two seed paths, but they belong to main's
scoped_skill_* mount views, which resolve under /projects. The production
runtime reaches skills through db_backed_skill_grants, whose namespace is
/tenants/<t>/users/<u>/skills — pinned by
production_skill_management_mounts_use_production_namespace. Seeding at
/projects/... wrote outside the tree the runtime scans, so both tests saw an
empty activation set.

* ci: map the skill self-creation e2e script in the Reborn test planner

Repo-root scripts/ is deliberately not prefix-classified, so adding
e2e-skill-self-creation.sh raised 'unmapped test or CI path' and failed
Detect Reborn test scope, which skipped every downstream Reborn lane. It is
referenced by no workflow and needs live model credentials no lane has, so it
selects no lane -- the same decision already recorded for run-reborn-webui.sh.

* chore: drop staged skill bundles from the tree and ignore .skills/

Activation stages a skill's bundle into the workspace, so running the agent
from the repo left 16 staged files committed under ironclaw_cli/. They are
build output of the feature this PR adds, never source, and they inflated the
diff by ~1500 lines.

* fix(skills): key the host-disk skill import per skill, not per store

A skill dropped into the store after the first boot was never imported, and
since skills read only from the database it stayed invisible — permanently,
because the store-wide marker outlives every restart.

The marker is not gratuitous: the disk copy survives a deletion made through
the product, so an import that only checks "is it already in the database?"
copies a removed skill straight back. Keying the marker per skill keeps the
migration one-shot per skill, which picks up a newly appearing one on the next
boot without resurrecting anything.

Both directions are pinned, and they fail in opposite directions on the old
code: a_skill_appearing_on_disk_after_the_first_import_is_still_imported fails
before this change, an_imported_skill_deleted_from_the_database_is_not_resurrected
fails if the marker is simply removed.

* test(skills): seed user skills before boot, and cover bundle staging

The three skill_activate cases seeded a user skill through the capability
harness AFTER the group was built. That worked while skills were read from the
host disk; they are read from the database tree now, and the store is migrated
into it at boot, so a later write was never picked up and the run listed the
system catalog with none of the user's own skills.

Seeds them through the harness instead, which writes the store before the
runtime boots — the ordering rule with_system_skill_fixture already documents,
and the shape a real user's existing skills have. Both ids come from the
group's already-resolved binding, since actor_user_id is an opaque hash that
cannot be rebuilt from the profile's owner string.

Also covers bundle_staging, which had one test for a 200-line file. The
path-traversal guard in staged_path had none at all despite being the boundary
that keeps a model-authored bundle inside its own directory; removing the guard
now fails a_bundle_path_that_escapes_the_skill_directory_is_refused. The rest
pin the best-effort contract: a bundle with nothing stageable advertises no
workdir, and one unsafe entry does not cost the safe files.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:48:29 +00:00
..