Commit Graph

3841 Commits

Author SHA1 Message Date
firat.sertgoz
3f1faa66b2 fix(webui): keep failure bubbles with their prompts (#7301)
* fix(webui): keep failure bubbles with their prompts

* fix(webui): address ironloop review — preserve request failure pairs (#7301)

* fix(webui): keep stream failures at refresh boundary (#7301)
2026-08-10 09:49:54 +00:00
jinxin
72bcf876b8 fix(webui): clarify no-progress run failures (#7422)
* fix(webui): clarify no-progress run failures

* test(webui): cover recovery-required run failures

* fix(webui): preserve durable phases on run failure
2026-08-10 09:37:43 +00:00
jinxin
432d9d0522 feat(webui): replace Logs toolbar selects with shared SelectMenu (#7335)
* feat(webui): use shared log level select menu

* test(e2e): cover logs level SelectMenu

* fix(tests): address logs select review feedback
2026-08-10 09:34:27 +00:00
jinxin
9faa6cd2b1 refactor(webui): share native file-picker interaction (#7337)
* refactor(webui): share file picker interaction (#7333)

* test(e2e): cover same-file picker reselection (#7333)

* test(webui): assert native file input reset (#7333)

* fix(webui): address file picker review feedback (#7333)
2026-08-10 09:28:06 +00:00
Coffee
2042380731 chore: remove IronLoop small-fix role instructions (#7415)
* chore: remove IronLoop small-fix role instructions

* ci: classify IronLoop configuration changes
2026-08-10 08:52:31 +00:00
firat.sertgoz
226bd491dd ci(canary): remove provider-matrix lanes and zizmor scan (#7418)
* ci(canary): remove provider-matrix lanes and zizmor scan

* fix(ci): classify nextest config as exhaustive-plan change

.config/nextest.toml is read by every Tests (Reborn) lane, so the
fail-closed planner arm raised 'unclassified pull-request path' on any
PR touching it, skipping all downstream Reborn lanes. Widen it to the
exhaustive plan like crate deletions.
2026-08-10 08:22:40 +00:00
firat.sertgoz
9dd228a62a fix(ci): clear inherited main check failures (#7425)
* fix(ci): scope POSIX trace test import to Unix

* fix(ci): run Windows WebUI setup with Bash

* test(ci): pin Bash for Windows WebUI setup

* fix(ci): clear remaining main check failures

---------

Co-authored-by: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com>
2026-08-10 08:17:31 +00:00
sergeiest
4e05a033d2 fix(webui): let a failed run's error bubble capture a trace (#7370)
A terminal run failure renders as a `role: "error"` chat message, which
never carried a `turnRunId` and was excluded from the existing
"download run artifact" action (gated on `role === assistant`). The
action-button row itself was also gated on `showActions ||
showRetryAction`, neither true for an error bubble, so the button
stayed hidden even once its role check was relaxed. Net effect: a
failed run had no way to capture a trace for it (#7369).

Thread `turnRunId` onto the run-failure message at creation, in-place
update, and through the unknown-to-known-run-id promotion path, and
let `showArtifactAction` fire for error messages that carry one. The
existing download handler needed no changes — it was already keyed
only on `threadId`/`turnRunId`, not role.

Fixes #7369.

Co-authored-by: Sergey <sergey@Mac.attlocal.net>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-10 08:05:20 +00:00
firat.sertgoz
4a5b3a6910 fix(webui): restore scoped attachment reads and SSE tests (#7341)
* fix(composition): read landed attachments through the per-caller workspace mount

/projects/workspace/tenants/{tenant}/users/{user}, but the loop-host
attachment_read_port still read through the shared read-only fixed view
(services.workspace_filesystem), which resolves the workspace root. A
landed image therefore came back NotFound at model-gateway time and was
silently dropped, so vision-capable model payloads lost every inline
image (the duration-4 Playwright attachment failure).

Wire the read port over the same per-caller scoped handle the WebUI
lander uses (runtime_mounts::read_write_workspace_filesystem), mirroring
what #7062 already did for the channel-host assembly. Under the Shared
policy the handle is byte-identical to the old fixed view; under
PerCaller it now resolves the caller's subtree.

* test(playwright): reconcile legacy WebUI v2 suites to the fetch-based SSE transport

event-source-plus (fetch/ReadableStream). The legacy suites still faked
window.EventSource, so the app never opened a stream and every
duration-1/duration-4 legacy test that emitted frames failed with "no
EventSource stream is open".

- Extract the smoke suite's proven fetch fake into
  install_fake_v2_event_stream() in reborn_webui_harness, extended to
  record request URLs and headers for reconnect assertions.
- Port all seven legacy scenario files onto it, updating cursor/token
  assertions to the header contract (Authorization bearer,
  Last-Event-ID) instead of the retired token/after_cursor query
  params.
- legacy_skills delete: use the shared in-app confirmation dialog
  instead of a native browser dialog.
- legacy_dom_resource_limits reconnect-timer: assert the pending
  reconnect is cancelled when the tab hides (the fetch transport
  schedules retries internally).
- legacy_rendering: assert no live onerror/iframe/img nodes instead of
  substring-scanning escaped text.
- extensions_api: restore the #6520 wire contract (retired
  authenticated/active/needs_setup/has_auth/onboarding_state booleans
  must be absent).
- tool_execution truncated-tool test: expect model_output_truncated
  failure per #6845's no-recovery contract instead of an assistant
  recovery message.
- streaming_run_control_api: drop the stream=true assertion for the
  OpenAI-compatible mock, which rides the buffered fallback since
  #7120 (rig-core cannot distinguish a complete stream from a truncated
  one).
2026-08-10 08:00:17 +00:00
firat.sertgoz
fa6e72d7c0 test(tool-search): add large-catalog baseline (#7409) 2026-08-10 07:55:26 +00:00
ironloopai[bot]
1439e5b360 fix(webui): render gemoji shortcodes in chat markdown (#7404)
Co-authored-by: aiworkbot <220660587+aiworkbot@users.noreply.github.com>
2026-08-10 07:39:41 +00:00
ironclaw-ci[bot]
cd38d8834e chore(agents): refresh codebase knowledge graph (#7412)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-08-10 06:57:27 +00:00
jinxin
9fd1e638a6 feat(inspector): complete statistics, navigation, and localization (#7291)
* feat(inspector): add operator inspection API

* docs(inspector): assign product service ownership

* test(inspector): ratchet diagnostic contracts

* feat(inspector): add debug panel shell

* test(inspector): cover debug panel shell e2e

* fix(inspector): stop diagnostics when panel closes

* feat(inspector): add prompt inspection

* fix(inspector): follow current webui ownership

* feat(inspector): add model call statistics

* test(inspector): cover model statistics e2e

* fix(inspector): avoid uncollected tool metrics

* test(inspector): cover prompt diagnostics e2e

* test(inspector): align statistics e2e scope

* fix(inspector): redact prompt metadata

* fix(inspector): preserve per-call model identity

* fix(inspector): classify prompt instruction sources

* test(inspector): assert reported token usage

* feat(inspector): add activity timeline and turn navigation

* test(inspector): cover activity timeline in browser

* fix(inspector): read current run before publishing activity

* feat(inspector): add bounded tool execution details

* test(inspector): cover bounded tool details in browser

* fix(inspector): validate retained tool result sizes

* test(inspector): add security and operator coverage

* test(inspector): cover browser workflows end to end

* fix(inspector): address review feedback

* fix(inspector): retry transient snapshot failures

* fix(inspector): address prompt diagnostic review findings

* fix(inspector): follow debug query navigation

* feat(inspector): complete frontend diagnostics

* test(inspector): cover frontend parity in browser

* fix(inspector): preserve stream terminal state

* fix(inspector): capture full capability surface

* fix(inspector): scope projection activity to its run

* fix(inspector): harden activity diagnostics

* fix(inspector): bound tool result diagnostic capture

* fix(inspector): harden tool diagnostic pipeline

* fix(llm): request usage for NEAR AI streams

* fix(inspector): address prompt diagnostic review feedback

* fix(webui): harden inspector stream coverage

* fix(inspector): preserve debug session statistics

* fix(inspector): keep diagnostics active while hidden

* test(e2e): cover hidden inspector observation

* fix inspector model call stats review findings

* fix inspector refresh and truncation regressions

* fix(inspector): address activity timeline review feedback

* fix(inspector): harden activity lifecycle handling

* fix(composition): move tool diagnostics to loop host

* fix(inspector): keep a settled stream live and complete locale parity

A live diagnostic update's debounced snapshot refresh was announcing LOADING,
so an open, healthy stream read as "Connecting" indefinitely once a run
settled — the settling stats update is the last one. That refresh is now a
background read. Incomplete snapshot statistics no longer accumulate as real
zeros, browser-session inspector state is namespaced by the authenticated
caller, an evicted pinned run rejoins the latest turn instead of the oldest,
tool status is localized, and the inspector strings now cover all ten locales.

* test(inspector): put the inspector locale sidecar under the parity gate

The inspector's English copy is registered from its lazy chunk instead of
src/i18n/en.ts, so the all-locale parity test — which derives the required key
set from en.ts — never covered those keys; a locale could drop one and fall
back to English silently. The test now treats the English key set as the union
of en.ts and a declared sidecar list. Keeping the copy in en.ts is not an
option: measured, it puts /chat at 217.4 KB gzip against a 217.0 KB budget.

* fix(inspector): reject malformed model breakdowns and correct locale copy

A `calls_per_model` entry with a negative or non-integer `calls` passed the
statistics decoder and was then coerced to zero during accumulation without
marking the breakdown truncated, presenting a fabricated "0 calls" for a model.
Every entry is now validated before a record is accepted. German turn
navigation used "Zug" (a train, or a game move); it now reads "Runde", with the
determiner agreement that noun requires. Spanish and Portuguese tool-status
values were written feminine against a masculine "Estado"/"Status" label.

* fix(inspector): bound the model breakdown before scanning and retaining it

The statistics decoder validated every calls_per_model entry but never the
array length, so an out-of-contract response was scanned in full and then
retained by the accumulator for up to 128 runs. The host truncates this
breakdown at MAX_MODELS_IN_STATS and reports it as truncated, so a longer
array cannot conform; the client now mirrors that ceiling and rejects the
record before the scan.

* fix(inspector): align turn navigation with host diagnostic retention

The browser offered 32 turns of navigation per thread while the host retained
diagnostics for 2 runs per session, so every turn past the second rendered
blank. Each layer was individually correct and the e2e scenario stopped at two
turns, so nothing saw the dead zone. Retention moves to 4 and the navigation
window mirrors it, pinned by a new architecture gate that reads both constants;
the scenario now walks back two turns and asserts real activity. Retention is a
ceiling as well as a default, and capture is unconditional, so 4 is a resident
memory choice — roughly 80 MB worst case across the eight tracked sessions.

* fix(composition): delimit the i18n bundle guard with an i18n-owned marker

The guard sliced the concatenated chunk bundle from the i18n provider up to
`QueryClient`, a symbol another module owns, so the segment's extent tracked
Rollup's chunk boundaries. A split that merely folded react-query into the
entry chunk removed that marker from everything appended after the provider and
failed an i18n guard with no i18n change. It now ends on the AVAILABLE_LANGUAGES
literal that follows the provider in the same module; string literals survive
minification, and every existing assertion holds against the tighter segment.

* fix(architecture): resolve the inspector gate's SPA path through crate_path

The gate joined a family-nested literal onto the workspace root, the idiom
crate_path exists to replace: a crate family move would have turned this into a
read failure rather than a resolved path. It now names the SPA file in the
logical flat spelling and resolves it, and the assertion reports the resolved
path so the message still points at a file that exists.

* test(inspector): follow a pinned turn explicitly when a new turn arrives

The multi-turn scenario assumed the panel would jump to an arriving turn, but a
selection the operator navigated to is deliberately sticky: the new turn widens
the window without yanking them off the turn they are reading. The scenario now
asserts that guarantee, then clicks Latest to follow, then walks back two turns
as before. Verified by running the inspector scenarios locally rather than by
reading, which is how this slipped through the first time.
2026-08-10 06:12:10 +00:00
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
firat.sertgoz
6384fa6c02 ci(nightly): grant actions: read to the reborn-tests call contract (#7323)
reborn-tests.yml's coverage-report job requests job-level `actions:
read` since #7018 (it fetches the base commit's merged-lcov artifact for
the changed-line coverage gate). GitHub validates called-workflow
permissions at trigger time, and the nightly caller grants only
`contents: read` + `pull-requests: write` — so every scheduled run
since 2026-08-03 (first run at 3be5f056e) has died as a startup_failure
with zero jobs, and the run's own failure-reporting job dies with it.

Add the missing scope and record why in the contract comment next to
the call.
2026-08-09 12:30:28 +00:00
ironclaw-ci[bot]
c7ec378f4e chore(agents): refresh codebase knowledge graph (#7399)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-08-09 09:25:32 +00:00
Benjamin Kurrek
0b5fcde996 feat!: a run acts as its invoker — remove shared-route subject binding (#7157 follow-ups) (#7377)
* feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted

Re-landed PR #7157 on current main (aa8b748c3967f33aa4, across the WS2
composition inversion, the WS6 crate renames, and the WS7 family moves), with
all 44 review comments dispositioned.

Two-lane delivery model: a run's final reply always lands in its own
conversation (lane 1); reaching any other surface is the model's explicit
`builtin.outbound_deliver` call (lane 2 — bot identity, one catalog target
per call, synchronous through the DeliveryCoordinator, provider-issued
message refs as evidence). Background-run notices fan out to a user-configured
notification-channel set (new record field + read-side legacy migration,
`builtin.notification_channels_set`, first-approve-wins, WebUI multi-select).
The stored delivery heuristics are deleted (route_current, builtin:web_app,
outbound_delivery_target_set, per-trigger delivery_target_id + precedence
chains + four-slot preference fallback), with an idempotent boot migration of
stored trigger targets into explicit prompt steps and the retired vocabulary
pinned in reborn_retired_taxonomy.rs.

Port notes (old → new homes): ironclaw_reborn_composition→app/ironclaw_composition,
ironclaw_product→product/ironclaw_assistant, first_party_extensions→extensions/packages,
run-profile vocabulary→ironclaw_loop_contracts, PreferenceTargetCodec→
ironclaw_extension_contracts, wire DTOs→ironclaw_product_contracts::product_wire.
The model-delivery implementation moved extension_host→assistant
(CoordinatedModelChannelDelivery) — the WS2 port inversion forbids extension_host
naming product types; the deferred-slot registration and post-coordinator bind now
live in composition's production assembly, mirroring TriggeredRunDeliveryDriver.

Re-folded 2026-08-05 onto b2023bc8f (#7258): channel-adapter vocabulary
re-imported from ironclaw_extension_contracts, product-adapter/inbound
vocabulary from host_api/product_contracts, module-charter map's outbound
row renamed to the two-lane vocabulary. Post-branch CI gates adapted in
the same change: skills/ classified in the PR test planner (test-first,
sabotage-verified), panic baseline ratcheted down, nested test fixtures
renamed to the scanner-sanctioned support_tests.rs shape, composition's
inline trigger-migration tests split to tests.rs (mass budget green with
no ceiling raise), extension_contracts size ceiling 7727 -> 7748 (+21:
the ActivePreferenceTargetCodecs port), loop_contracts ceiling
re-captured down 14479 -> 13850 after the delivery-vocabulary deletion.

Third fold 2026-08-05 onto b72d7da66 (#6831, standardized messaging
framework): the two-lane guidance moved into the canonical messaging core
prompt (host_api prompts/messaging/send_message.core.md), now naming
builtin__outbound_deliver with the arrive-twice and trigger caveats for
every messaging extension; slack vendor addendum/manifest taken as #6831
shipped them; ceiling-table union (host_api 18570 beside this PR's two
re-captures); retired slack schema embed and deleted preferences
capability stay deleted; golden context-surfacing snapshot regenerated
(one surface-hash line).

Fourth fold 2026-08-06 onto c69ed2d70 (#7263 program-closure batch +
sibling fixes): ceiling-table union (product_contracts 15685 from #7230
beside this PR's re-captures) and main's tracing-target syntax sweep
(target = -> target:, gate-enforced) applied over this PR's kept lines;
deleted delivery-heuristic code stays deleted.

Fifth fold 2026-08-06 onto 0c297cb24 (#7264 guidance-layer sweep):
zero conflicts; guidance/doc-pointer changes auto-merged over this delta.

Routing-UX slice 2026-08-06 (product thread + follow-ups): result routing
is prompt-owned with a pinned source-surface default (bare "send me" =
the surface you asked from; web app = no delivery step; explicit
destinations override, one delivery step each) — iterated against live
recordings until a real model followed it, with two live-recorded QA
fixtures (bare-webui, multi-channel) plus contracts and replays. The
automations-page panel is retained as the notification-channel selector
(notices only); the conversational notification_channels_set tool writes
the same validated set.

Delivery-evidence fix (theredspoon's flag; #7029 fixes the same swallow
on main): mark_terminal reports whether the durable write committed and
a confirmed send whose Delivered row failed to commit returns
DeliveredUnconfirmed (refs retained, durably_recorded: false), never a
fabricated Delivered — regression-tested and sabotage-verified. Plus a
CodeRabbit triage batch: correctable coordinator errors stay
model-visible, omitted target_ids no longer clears the set, the success
schema requires evidence, the composition outbound facade is dissolved,
and guidance/contract docs are aligned.

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

* fix: harden channel delivery routines and replay

* fix: preserve automation loading and identity freshness

* fix: close channel delivery review defects

* fix: skip paused routine catch-up slots

* ci: record channel delivery composition budget

* test: align composition baseline with channel delivery

* fix(auth): survive interrupted OAuth callbacks

* fix(auth): keep callback coordination panic-free

* fix(ci): reconcile channel delivery merge seams

* fix(ci): recapture merged contracts ceiling

* fix(delivery): close review findings across delivery, migration, and guidance

Fixes the findings from the multi-agent review of this PR. Every behavioral
fix ships with a regression test that fails before it.

CI (red on this head)
- `standalone_yolo_notification_channels_set_bypasses_approval_gate`
  expected the shared "invalid outbound delivery request" summary for a
  `builtin__notification_channels_set` call. Production deliberately
  specializes that message per operation and pins it with
  `notification_channel_failure_names_the_operation_the_model_can_correct`;
  the assertion was the stale side.

Delivery evidence (kernel + assistant + outbound)
- `AlreadyDelivered` replays reported `delivered: false` "unverified"
  because the ledger row retains no provider refs, inviting the duplicate
  resend the at-most-once claim exists to prevent. Evidence gained
  `already_delivered`; a replay now reads as delivered with an honest
  "not resent" summary. The classification suite had no `AlreadyDelivered`
  case at all, which is why this shipped.
- `DeliveredUnconfirmed` is the one non-`Delivered` outcome that actually
  sent something, but `delivered_messages_from_outcome` dropped its refs,
  so gate reply-routes went unrecorded and a live OAuth prompt could never
  be retracted on that path.
- `content` is now rejected when empty: the input schema advertised
  minLength 1 and nothing enforced it, so empty content reached the channel
  as an empty part and returned an opaque provider error.

Background-run notifier (assistant)
- One run legitimately emits several `RunBlocked` notices (re-auth
  stand-in, unserviceable-auth cancellation, run failure), but all three
  derived the same projection ref, and the delivery id hashes it. The
  second notice to a target came back `AlreadyDelivered`, was treated as
  success, and was never sent — a user could be told a routine needed
  re-authorization and never told it then failed. Notices carry a
  discriminator; once-per-run kinds keep their historical id shape, so
  existing delivery identities are unchanged.
- When every catalog lookup failed, the empty result was recorded as
  `NoDefaultConfigured`, reporting a backend outage as the benign "user
  configured nothing" state. It now records `Failed`.

Boot migration (composition)
- The retired `builtin:web_app` target meant "no external delivery". It was
  being rewritten into a delivery step to an id nothing can resolve,
  inverting the stored intent on every later fire. It now clears without
  adding a step.
- One unmigratable row aborted the entire composition boot, with the error
  telling the operator to shorten a prompt through the UI that no longer
  starts. It now pauses its own routine — a paused trigger cannot fire, so
  "never fire unrouted" still holds per record — and boot continues. Only a
  systemic store failure stays boot-fatal. A row deleted during the CAS
  retry ends that record instead of failing boot.
- The CAS retry loop, its bounded exhaustion, and the vanished-row arm had
  no caller-level coverage; adds a delegating repository double that forces
  CAS misses. The prior fail-closed test is rewritten to pin the invariant
  it documented (route survives, record not half-migrated) under the new
  per-record mechanism.

Model-visible messages (composition)
- The targets-list denial said "not permitted to change the outbound
  delivery target" for a read-only call, and the lease denial named the
  retired delivery-target concept on the notification-channel path that is
  its only production caller. Both are now operation-specific and pinned.

WebUI (frontend)
- `setNotificationChannels()` with no argument posted `target_ids: []`,
  turning an omitted argument into a destructive clear-all and defeating
  the backend contract that deliberately rejects an omitted field.
- The notification-channels panel stayed editable after a failed read, so
  toggling one row full-replaced the stored set from an empty baseline and
  silently dropped every channel the user never saw. Editing is now locked
  on a failed read, with a rendered explanation.
- Adds the missing `tools.description.builtin.notification_channels_set`
  key to all 11 locales, plus save-failure coverage for the hook (which was
  correct, but untested) and locale-parity tests.

Guidance
- The new `.claude/rules/tools.md` was ported from a pre-restructure branch:
  it named `ironclaw_dispatcher` (deleted) and `ironclaw_extensions` (never
  existed), and its review command grepped three paths removed by WS6/WS7.
  Its `paths:` frontmatter also never matched the product/composition
  callers its rules govern, so the rule never loaded for them.
- `ironclaw_loop_contracts` now records both embedded prompt assets; this
  PR added a second one while the crate's Known-debt entry still said one.
- Bumps `skills/delegation` (rewritten guidance, unlike its two siblings in
  this PR which both bumped) and fixes a pre-rename path in the
  extension-runtime checklist.

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

* fix(delivery): unify the DM-rule enforcement point and re-ratchet composition

CI (composition mass budget, red on the previous head): the per-record
migration quarantine pushed composition 6 LOC over its absolute ceiling.
Resolved by the reduction the budget file itself blesses rather than a
raise — `runtime/approval.rs`'s 417-line inline `#[cfg(test)]` module split
verbatim into `runtime/approval/tests.rs` (the gate excludes test-only
files but counts inline test modules). Composition is now 40,432 LOC,
smaller than before these fixes, and `loc_ceiling`/`loc_observed` plus the
arch-test record are re-captured together at the measured value per the
gate's one-directional ratchet rule.

The codec-scan that decodes a binding and enforces "an OAuth authorization
URL only ever lands in a personal DM" existed twice — once in
`TriggeredReplyTargetAuthority`, once as `CodecChannelTargetResolver` —
with both copies commented as "the single enforcement point". They are now
one implementation, shared by the notifier and `builtin.outbound_deliver`,
with a context label so each path keeps its own diagnostic.

That rule turned out to be UNGUARDED: sabotaging it (`if false && ...`)
failed no test in the crate. The vendor codecs pin the predicate in
isolation and the coordinator test pins rejection handling with a double
that decides the verdict itself, so nothing covered the wiring that joins
them. Adds a contract test driving the real resolver through
`DeliveryCoordinator::deliver` for both verdicts, asserting a non-DM target
never reaches the vendor adapter. Sabotage-verified: the test fails with
the rule disabled and passes with it restored.

Smaller findings: the notification-channel schema cap now derives from
`ironclaw_outbound::NOTIFICATION_TARGETS_CAP` instead of hand-mirroring
`8`; `triggered_run_delivery`'s module and trait docs described the retired
result-push model this PR deletes; the two new notification strings used a
different brand spelling and dash style from the nine siblings in their own
module; and several new comments navigated by pre-rename paths
(`ironclaw_product::`, `local_dev::`, `crates/ironclaw_webui/`) plus a
citation of a test symbol that does not exist.

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

* fix(outbound): scope the delivery catalog to the authenticated actor

`builtin.outbound_deliver` resolved its destination catalog under
`ResourceScope.user_id` while performing the send as
`authenticated_actor_user_id`. Those are the same user on a personal thread
and on an automation fire, but they diverge on a shared-route channel
conversation: the scope user is the route's SUBJECT
(`TurnScope::explicit_owner_user_id`) and the actor is whoever sent the
message. Any participant of such a channel could therefore name the
subject's target ids and push bot-identity content into the subject's own
destinations — their personal DM included — from a conversation the subject
may never read.

The catalog now follows the actor, so a caller stays inside their own
connected surfaces on every path and an unfamiliar target simply does not
resolve. Behavior is unchanged wherever owner and actor already agree,
which is every non-shared-route path.

Regression test drives the divergent case through the port (participant
denied with `TargetUnavailable`, nothing reaching a vendor adapter) plus a
control proving the owner's own delivery still works. Sabotage-verified:
restoring owner-scoping fails it.

NOT changed here, and flagged for a product decision: the sibling
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derive their caller from the same
owner-preferring `effective_user_id`, so on a shared route a participant
can still enumerate — and, with the approval gate auto-approved, rewrite —
the subject's notification channels. That helper also scopes approval
gates and capability leases, so flipping its precedence risks breaking
approval raise/resume matching in a path no test covers; it needs its own
change with that coverage.

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

* fix(delivery): stop rewriting the DM-target row on every message

The post-admission backfill calls `FilesystemChannelDmTargetStore::upsert`
for every admitted inbound direct message, and the store unconditionally
wrote a fresh row. After the first message the stored record is already
correct, so the steady state was one durable backend write per DM message,
forever, whose only effect was a new `updated_at` — and each message's
reply-delivery observation was serialized behind it. An unchanged record
now short-circuits; the existing row is loaded here anyway to preserve
`created_at`, so the comparison costs nothing.

Also adds the regression test the `NoDefaultConfigured` -> `Failed`
classification fix landed without: the notifier's `SkipEntry` lookup lane
had no coverage at all (no test ever made a catalog lookup error), so
neither the skip nor the all-failed arm was exercised. The triggered
harness gains an injectable catalog provider for it. Sabotage-verified.

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

* fix(loop): bound the connected-channels line so the runtime slice fits

Confirmed live, not theoretical: a worst-case runtime context renders 4,391
bytes against the 4,096-byte `PromptTextSurface::SafeSummary` cap that
`instruction_bundle::push_runtime_context` validates the whole slice on —
and exceeding it is a run-ending error on EVERY prompt build for that user,
not a one-off.

This PR's fixed ~1.1 KiB delivery-guidance block is what pushes a
previously-fitting context over. The individual parts are each bounded
(location 200 chars at its producer, locale 35, per-label safe-text
validation), but nothing bounded their SUM, and the connected-channels line
is the one part that grows without limit: up to 20 entries whose names and
presentation hints are only individually capped.

That line now renders as many channels as fit a 1 KiB budget and folds the
rest into the "+N more" counter it already carried, so the fixed guidance
can never be squeezed out by variable content. The worst-case test that
found this stays as the pin.

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

* fix(outbound): resolve outbound capabilities as the acting user

`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derived their caller from
`effective_user_id`, which prefers the thread owner over the actor. Those
agree on a direct message and on an automation fire, but diverge on a
shared-route channel conversation, where the owner is the route's
configured subject — the deployment operator by default
(`channel_workflow.rs`) — and the actor is whoever posted.

So any participant of a shared channel could enumerate the operator's
connected destinations and rewrite the operator's notification-channel set,
which is where approval prompts, re-auth prompts and failure notices are
delivered. The caller now follows the acting user, matching the fix already
applied to `builtin.outbound_deliver`.

This deliberately REVERSES a previously pinned preference. Two tests
asserted the owner won when the two differ; that pin predates shared-route
subjects defaulting to the operator, and it contradicts the rule that a run
acts as whoever invoked it. Both are updated to pin the actor, with the
reversal recorded at each site rather than silently relaxed, and the
notification-channel write is now asserted to land under the acting user
with the thread owner's own set left untouched.

INTERIM, by design: `resource_scope_for_run` and `settings_scope_for_run`
still follow the owner, because they scope the approval-gate raise and the
capability lease and those must stay matched between raise and resume.
Unifying them belongs with the follow-up that removes shared-route subject
binding entirely so a shared channel runs wholly as its invoker; that needs
approval raise/resume coverage which does not exist yet. A new test pins
the split so the interim state is explicit rather than accidental.

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

* fix(ci): keep loop_contracts under its size ceiling and ratchet down

The runtime-context byte-budget fix and its worst-case pin pushed
`ironclaw_loop_contracts` to 14,032 production lines against a 13,949
ceiling. Resolved by the reduction the gate prefers over a raise:
`runtime_context.rs`'s 919-line inline `#[cfg(test)]` module split verbatim
into a `runtime_context/tests.rs` sibling, which `production_rust_files`
excludes (an inline test module inside a production file is counted; a
test-only file is not).

The crate now measures 13,115 — 834 lines below the previous ceiling and
smaller than before this review round — so the ceiling is re-captured
downward at the measured value rather than raised, per the gate's
one-directional ratchet. Count read from the gate's own failure message,
not by eye.

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

* fix(delivery): key observer gate notices by their gate ref

One run can park on several approval/auth gates in sequence (the observer's
blocked-state loop re-announces whenever the (status, gate) marker changes),
but the live observer derived every gate notice's projection id with no
discriminator, so all ApprovalNeeded notices in one run collapsed to a single
durable delivery identity. The second gate's prompt came back AlreadyDelivered
from the coordinator, was treated as success, and was never sent — the user
was never told about the gate their run was parked on, and no reply route was
recorded for it, so a bare `approve` could not resolve it either.

Key the projection id by the notification's gate ref (the mechanism #7157
added for the triggered notifier's RunBlocked notices). A repeat announcement
of the SAME gate still dedupes; kinds that carry no gate ref (FinalReplyReady)
keep the historical undiscriminated id shape so existing delivery identities
are not re-keyed.

Regression: observer_delivers_a_prompt_for_each_distinct_approval_gate drives
the real DeliveryCoordinator over the real outbound store through two distinct
scripted gates and asserts two delivered prompts plus a recorded reply route
for each. Sabotage-verified: reverting the discriminator to None fails exactly
this test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(composition): pin the notification-channels gate dance when owner ≠ actor

The full builtin.notification_channels_set approval dance — raise, replay
payload, user approve (store + lease mint from the stored row), approved
resume, lease claim, dispatch, lease consume — driven through the real
capability port on a run whose thread owner differs from its acting user.

Pins two properties ahead of unifying the scope derivation onto the actor:
raise and resume must derive the same scope (every store in the dance is
scope-keyed, so a half-unified derivation strands the approved capability),
and whose identity that scope carries (the thread owner, under the interim
split #7157 shipped). The approve step mints the lease from the stored
request's own scope, grantee, and fingerprint — the same material the
production click-approval resolution uses — never a re-derivation.

Capability-host tier rather than tests/integration because the product rule
"a run acts as its invoker" makes owner ≠ actor unconstructible through every
product front door; the run-context shape remains legal kernel state (runs
parked across the deploy boundary carry it). The owner == actor dance stays
covered end-to-end at the integration tier (outbound_target.rs).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(outbound): scope the whole notification-channels gate dance as the acting user

Unify the interim #7157 split: resource_scope_for_run and
settings_scope_for_run now derive from the acting user like caller_for_run
already did, and effective_user_id (the owner-first ladder) is deleted. The
approval-gate raise, the replay payload, the durable gate record, the lease,
and the approval-settings read all follow the user who invoked the run — so
the invoker sees and approves the gate, and their settings govern it.

The raise/resume coverage added one commit earlier ran before and after this
change and caught a real half-unification in between: the resume-side replay
load lives in ironclaw_loop_host's synthetic-capability wrap (a different
crate from the raise-side save in notification_channels_set) and still
derived owner-first, stranding an approved resume with "replay payload is
unavailable". The acting-identity ladder now has exactly one definition —
LoopRunContext::acting_user_id in ironclaw_loop_contracts — and both sides
delegate to it, so the hand-synced-copy class is closed rather than re-synced.

notification_channels_set's replay/gate-record writes move from the
capability_host-wide owner-first helper onto the outbound module's
base_resource_scope_for_run so every store in one dance derives one user; the
capability_host-wide helper itself is unchanged (thread/durable-result
scoping legitimately follows thread ownership, and owner == actor on every
binding created under the run-acts-as-invoker rule).

Loop-contracts size ceiling: +16 lines for the shared ladder, paid for by
splitting host/run_context.rs's 104-line inline #[cfg(test)] module into its
run_context/tests.rs sibling; ceiling re-captured DOWN 13_115 -> 13_028 from
the gate's own failure message.

Runs raised before this change with owner != actor and resumed after it will
miss their replay payload once and fail closed; re-requesting approval
recovers. Documented in the PR body.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(conversations): key shared-route bindings per (conversation, actor)

A run acts as the user who invoked it, so a shared conversation binds one
thread per paired actor — each owned by that actor — instead of one
conversation-wide thread owned by a configured subject. BindingKey gains a
serde-defaulted shared_actor_user_id component (None for Direct routes, whose
identity stays the conversation alone); the trusted-owner parameter is
deliberately ignored on Shared creates (it remains the trigger lane's way to
bind Direct conversations for their creator), and the legacy shared-owner
backfill is removed with it.

Migration is ignore-but-retain, pinned with a restart-path test: legacy
Direct keys deserialize byte-identically (continuity), while legacy
conversation-keyed shared rows deserialize to a key no per-actor lookup
builds — retained in durable state untouched, and every participant
(including the old subject) starts a fresh thread they own.

Morphed legacy pins record what became structural: a shared probe/lookup can
no longer address (or widen) a Direct binding at all; stored reply targets
are isolated per actor; an actor's unpair cannot take the conversation away
from other participants' own threads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(product)!: remove shared-route subject binding; scope = invoker

Owner ruling: a run acts as the user who invoked it, in a DM and in a shared
channel alike, with one thread per (conversation, user). This removes the
subject half of shared-route configuration end to end and keeps the admission
half, fail-closed:

- ironclaw_product_contracts: subject_route becomes shared_admission — the
  SharedConversationAdmission port answers only "is this shared conversation
  connected"; ProductConversationRouteKey survives as the admission key.
  ResolvedBinding loses subject_user_id (retired-field JSON still
  deserializes; persisted-shape test updated); the actor is the one identity.
- ironclaw_assistant: ProductInstallationScope drops the default-subject,
  static-route, and subject-resolver knobs for one shared_conversation_admission
  port; resolve/lookup/reset check admission fail-closed (no port wired, or an
  unlisted conversation, rejects with a not-connected BindingRequired);
  resolve passes no trusted owner — the conversations domain keys and owns
  shared bindings by the paired actor. Thread and turn scopes derive their
  owner from the binding's actor on every route kind.
- ironclaw_extension_host: channel_subject_routes.rs becomes
  channel_shared_admission.rs; ChannelConfigSharedAdmission admits by
  membership in the operator-saved *_allowed_channels JSON array; the managed
  derived subject (user:{ext}-channel:{sha16}) is deleted; legacy
  *_subject_routes values are inert (pinned by test). Shared conversations are
  no longer offered as per-user notification delivery targets — their
  ownership came from the retired subject map — and stored channel-target
  preferences fail closed at resolution; DM targets are unchanged.
- slack manifest: slack_shared_subject_user_id and slack_subject_routes are
  retired with a gravestone comment; slack_allowed_channels is the admission
  surface (saves to the retired handles already fail closed as unknown
  fields — the extension-config analog of the config.toml retired-section
  gravestone).
- architecture tests: the INVERTED_PORTS row moves with the port rename.

User-visible consequences (also in the PR body): each shared-channel
participant now gets their own persistent thread and must be paired; no
cross-user shared context; the operator's identity is never a fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(reborn): align guidance, specs, and live-QA scripts with invoker scope

Guidance rows and the moved-ports list rename the inverted port
(SharedConversationAdmission, ex ProductConversationSubjectRouteResolver);
the assistant boundary prose states the new rule (one thread per
(conversation, actor), admission is the only shared-conversation
configuration, fail-closed on resolve/lookup/reset). The composition
CONTRACT.md's never-shipped per-channel subject admin API section is excised
with a dated correction; CHECKLIST/PROPOSAL get dated amendments beside the
historical text. Operator docs teach slack_allowed_channels + per-user
pairing. CHANGELOG records the behavior change and the retired config
fields. The live-QA scripts drop subject handling for allowed-channels
admission (200 script tests green), and the orphaned canary env var is
removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* feat(telegram): connect group chats via telegram_allowed_channels

Fail-closed shared-conversation admission left Telegram groups with no
operator affordance to connect one — the manifest declared no
*_allowed_channels handle, so every group/supergroup @-mention was
unadmittable. Declare the handle (the same generic [channel.config]
convention Slack uses): listed chats are served with each participant
running as themselves once paired; unlisted groups stay fail-closed.
Previously any group the bot was added to ran as the deployment operator,
which is the exposure this branch removes.

Surfaced by the integration scenario
telegram_update_becomes_a_turn_and_a_coordinated_reply failing closed after
the admission change — kept red until this ruling rather than narrowed to a
private chat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* test(reborn): morph the test tier to invoker scope

Every fixture and pin that carried the retired subject model moves to the
per-actor rule, with a recorded rationale at each semantic morph:

- extension-host channel e2e: an admitted (allowed-channels) shared channel
  runs as the paired actor; unlisted conversations stay rejected; stored
  shared-channel outbound targets and binding refs fail closed; the
  telegram supergroup journey admits its chat via the new
  telegram_allowed_channels handle and proves the reply as the invoker.
- assistant contract suites: admission replaces subject-route coverage
  (recording/failing/admit-all doubles; not-connected rejections on
  resolve/lookup/reset including existing bindings — a deliberate flip from
  the old existing-binding exemption; admission precedes actor-pairing side
  effects; direct routes never consult admission; per-actor threads for two
  participants; lookups never surface another actor's thread).
- root integration harness + journeys: the binding fake, thread/turn scopes,
  and the group canonical user derive from the actor; multi-actor isolation
  pins unchanged and strictly stronger.
- parity QA binary harness: subject resolution returns the actor.
- webui product API redaction pin: the new telegram admission handle joins
  the admin-metadata forbidden list.

Suites: extension_host 390/0; assistant 1084/0; conversations 105/0;
architecture suite full pass; integration bins: extension_delivery 21/0
(Postgres legs under colima), delivery_user_journeys 22/0, mcp 22/0,
trace_capture 14/0, generated_gate_sequences 29/0, group_journeys 16/0,
group_multiuser 14/0. Workspace cargo fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* docs(changelog): record the telegram_allowed_channels admission field

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q7eyxusG6UGBXBfJ8BnQDy

* fix(merge): reconcile composition ceilings and capability_wiring test arity

Post-merge fixups after folding main (#7157 squashed + #7214 + the
inspector prompt-diagnostic work) into run-acts-as-invoker:

- Re-capture the composition absolute-mass ceiling 40_747 -> 40_811 in
  both the budget manifest and reborn_restructure_baselines.rs: the
  acting-user scope helper and shared-admission wiring add +64
  production LOC on the merged tree. Recorded rather than parked in the
  150-line tolerance.
- Add the 10th `tool_diagnostic_sink` argument (None) to the invoker's
  capability_wiring test call — main grew the signature after this
  branch wrote that call site.

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

* fix(conversations): refuse legacy-row route-kind mismatches; drop unreachable widen

A Direct request is the one key shape a retained legacy conversation-scoped
shared row can collide with. Resolve, lookup, reset, and link now refuse the
mismatch outright (BindingRequired) instead of trusting adapters never to
re-classify a conversation's route kind — pinned by a Direct-probe leg on the
legacy restart-path test. The forward half of the migration contract is pinned
too: per_actor_shared_bindings_keep_their_threads_across_reopen proves a new
per-actor shared binding survives a restart (a deserialize-side regression
would previously have orphaned every group thread silently).

widen_binding_route_access and ReplyRouteAccess::allow_shared are deleted:
every Shared-keyed row is born shared under per-actor keying, so both widen
call sites were unreachable. The persisted flag stays for legacy reads.

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

* fix(delivery): key gate notices by gate ref on the triggered lane too

The gate-collapse fix shipped on the observer lane only; the background lane
still minted undiscriminated projection ids for ApprovalNeeded/AuthRequired,
so an automation run parking on a SECOND gate deduped to AlreadyDelivered,
recorded the whole delivery Failed, and the gate was never announced or
reply-routable (AGENTS.md: fix the sibling when a pattern bug is fixed).
TriggeredNotification's discriminator now carries the gate ref for gate
prompts (RunBlocked stand-ins compose their label with it), matching the
observer keying, with a triggered two-gate regression pinning outcome,
prompts, and both reply routes.

Also pinned: same-gate re-announcement dedupe (g1->g2->g1), two distinct AUTH
gates, and the refless id shapes incl. FinalReplyReady. Over-long
discriminators are bounded with a stable FNV-1a suffix so a maximal legal
TurnGateRef can never overflow ProjectionUpdateRef and silently lose a notice.

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

* refactor(identity): one contract derivation for every acting-identity scope

LoopRunContext::acting_resource_scope joins acting_user_id on the contract
type: the raise/resume scope recipe both gate-dance crates hand-synced is now
declared once, and every surviving ladder delegates — composition's
owner-first resource_scope_for_run (workspace/skill mounts) and the inline
grant-minting copy, loop_host's synthetic resume load, and
project_create_capability's effective_user_id (deleted; its doc claimed a
mirror that no longer existed). On the only run shape where owner and actor
differ — legacy runs parked across the deploy — mounts and grants now follow
the ACTOR like the rest of the dance; the pin flip is recorded in
visible_capability_request_uses_acting_user_for_runtime_scope.

The ladder is unit-pinned in its owning crate (all three rungs) and the
accepted deploy-boundary resume-miss is pinned on the synthetic port with an
acting-scope positive control. loop_contracts ceiling re-captured 13094 ->
13107 with provenance (the +13-line contract method).

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

* fix(extension-host): collapse admission handles; operator-identity channels never admit

ChannelConfigSharedAdmission now holds the one declared *_allowed_channels
handle as a plain String (the scan returns Option<String>): 'installed but
handle-less' is no longer representable and the per-request Option branch is
gone. The root pub use of the admission items is removed — consumers are
crate-local and use the module path.

Structural closure of the no-auth-vendor residual: a channel whose actor
identity is not per-user (no OAuth vendor, no pairing strategy) never
receives an admission resolver at all — an operator-identity channel that
admitted a group would run every participant as the operator, the exact
exposure run-acts-as-invoker removed. Previously this was unreachable only by
manifest inventory.

The extension_manager wire-shape pin gains the telegram_allowed_channels row
(production projection was already correct), and extension_delivery gains the
caller-path rejection leg: a correctly-signed webhook for an UNLISTED
supergroup is acknowledged but produces no turn and no reply.

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

* test+docs(reborn): re-pin parity to per-actor scopes; align contract, docs, vocabulary

The identity-parity bin now pins the run-acts-as-invoker property its fixture
can actually express: the shared-room support binding keeps ONE thread (the
per-actor thread model is locked at the integration tier by
scenario_two_actors_own_threads), and inside that shared thread each RUN's
scope is owned by its own invoking actor with identity context never
crossing. The shared-admission suite gains the reset checkpoint leg (deny
before rotation, thread survives), and the connect-nudge suite's shared leg
is documented as the deliberate unpaired-participant silence contract.

docs/reborn/contracts/conversation-binding.md (the owning contract) is
amended: per-actor key in rule 8, participant widening retired in rule 14,
subject ownership struck in rule 24, and the admission/retention semantics
recorded. Operator docs and CHANGELOG state the real unpaired-shared behavior
(silence; pairing via Extensions; DMs still nudge), the CHANGELOG gains the
both-lanes gate-announcement entry and Added-first ordering, CHECKLIST's
contradictory open-status is reconciled with a dated note, the new
REBORN_WEBUI_V2_LIVE_QA_SLACK_ALLOWED_CHANNELS is threaded through
live-canary.yml, observer.rs carries its arch-exempt annotation, and retired
'subject' vocabulary is renamed out of live test support and doc comments.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 20:52:45 +00:00
firat.sertgoz
30ae2d50f6 feat(stress): scripted tool-call workload with durable write read-back (#7360) (#7382)
* feat(stress): scripted tool-call workload with durable write read-back (#7360)

Phase 1 of issue #7360: teach the stress harness to drive real builtin
and memory tool calls through the production capability path and verify
their durable side effects.

The api-user-capacity mock LLM sidecar learns a deterministic scripted
state machine: the driver embeds an `ironclaw-stress-tool` marker in the
user message, the sidecar emits the scripted tool call for a tool
advertised in the request, the server executes it through the real
capability host, and the driver verifies the read-back verdict in the
final assistant message. Verdicts: confirmed / contended (same-user CAS
race, counted) / leak (cross-user isolation, hard failure) / missing
(write lost, hard failure) / undisclosed (tool never advertised).

Scripts: write_file_roundtrip (write_file + read_file of a unique
workspace path), memory_roundtrip / memory_grow / memory_mixed
(ironclaw.memory.write/replace-append + read of the shared
stress/shared.md target — every run doubles as a same-relative-path
isolation check). --api-scripted-doc-sizes cycles 4 KiB..1 MiB
documents with per-size buckets and submit-to-tool-visible /
submit-to-finalize stage latencies; --api-hot-writers spawns concurrent
same-user writers for hot-document CAS contention. Gated tools are
exercised through the per-user Tools auto-approve setting enabled during
setup via the production settings API.

Wired as a nightly leg in the hosted-single-tenant Postgres job (the
existing server stays up; the leg rebinds the mock sidecar on the same
port). Unit coverage: marker parsing, per-op step sequencing, tool-name
resolution (encoded/dotted/bare), verdict computation incl. leak
precedence, disclosure fallback, timeline helpers, per-size summary
buckets, and flag validation.

* fix(stress): hot writers on distinct user threads, size floor, CI server lifecycle

Review fixes for the #7360 Phase 1 scripted workload:

- Hot writers now run on distinct threads of the first user instead of
  sharing one thread, so concurrent operations exercise real per-user
  memory-document CAS contention rather than per-thread turn
  serialization. setup_users creates and records one extra thread per
  hot writer for user 0; run_hot_writer picks its own thread.
- Scripted document sizes are floored at 4 KiB (the token-dominated
  region below is meaningless and the issue's workloads start there);
  enforced in marker parsing and --api-scripted-doc-sizes validation.
- The CI scripted leg runs inside the server's run block so the trap
  does not kill the server before it starts; artifacts upload together.
- Wire-shape tests: mock_tool_call_response deserializes as the rig
  OpenAI CompletionResponse (stringified arguments, finish_reason
  tool_calls) and streaming tool-call chunks carry indexed delta
  tool_calls.

* fix(stress): hot-writer client action ids collide with the primary writer

A hot writer and the first user's regular writer shared the same user
label and operation index, so their client_action_id values were
identical and the server rejected the second submit with a 409
duplicate conflict. Include the scripted op prefix (h{k}-) in the
operation ref so concurrent writers always submit distinct action ids.

Found by a full local E2E run of the scripted leg against a real
hosted-single-tenant server: after the fix, memory_roundtrip with one
hot writer runs 9/9 clean (6 confirmed + 3 contended, 0 leaks) and
memory_grow runs 8/8 confirmed.

* fix(stress): address coderabbit review — verdict integrity, op-scoped tool counts, typed script key (#7382)

- compute_verdict: verdict comes from read steps only (write echoes can no
  longer mask missing/contended)
- timeline tool evidence: count tool results by sequence above the op's
  baseline instead of subtracting page-limited absolute counts
- timeline verdict match: delimit prefix by trailing space so op 1 cannot
  terminate on op 10's message; parse_result_verdict aligns on substring
- background users namespace markers as b{index} so cross-cohort leaks
  cannot read back as their own token
- hot writers drain in a dedicated JoinSet (no run_virtual_user refills)
- fraction chunks derive from cumulative boundaries so split writes persist
  exactly the configured size (regression test at 4097)
- ScriptKey derives clap::ValueEnum: CLI, marker wire format, and parsing
  share one string mapping; --api-hot-writers rejects write_file_roundtrip
- parse_marker bounds identity grammar; poisoned mutex recovery; sorted
  stage latencies; single conversation parse per completion request;
  CLI-level scripted validation test and doc-size bound coverage

* test(stress): cover --api-wait-for-assistant gate in CLI-level scripted test (#7382)
2026-08-08 16:57:36 +00:00
Pranav Raja
102e5e05b8 fix(skills): the model chooses the skill, not a keyword scorer (#6938)
* 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>

* 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): 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>

* 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>

* 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>

* ci: classify skills/ in the Reborn test planner

`Detect Reborn test scope` failed this PR outright:

    Reborn PR test planner failed: unclassified pull-request path: skills/ceo-setup/SKILL.md

The planner fails closed on any path it cannot attribute, and nothing had ever taught it about
`skills/`. This PR is the first to edit the bundled catalog (seven SKILL.md descriptions shortened
to satisfy the routing-metadata lint's 250-char cap), so it is the first to hit it.

Routed to `ironclaw_extension_host` through the existing `EMBEDDED_ASSET_OWNERS` table, which is the
mechanism for exactly this: its `build.rs` walks `skills/` and embeds the catalog as
`EMBEDDED_REBORN_SKILL_{SUMMARIES,BUNDLES}_JSON`. A SKILL.md edit changes what every fresh tenant is
seeded with, so it must select that crate's lanes.

The table's staleness test then rejected the entry — "routes to ironclaw_extension_host, which
embeds nothing from it" — and it was right to, given what it could see: `_crates_embedding` only
recognises `include_str!`/`include_bytes!` literals, and the skills catalog is embedded by a build
script, so the crate's only `include_str!` points at `OUT_DIR` and names no asset path. Taught the
detector the second mechanism rather than weakening the check: a build script that reads a tree must
declare `cargo:rerun-if-changed` for it, and that declaration plus the tree's name is what a
build-time embed looks like from outside. 66/66 planner tests pass.

Also fixes two `skill_learning` refiner fixtures whose `keywords: [file, count]` this PR's own
blocking lint refuses — every token generic, which is the `coding`-declares-`file` case the rule
exists to stop. Now specific multi-word terms.

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

* test(skills): pin the listing header this PR actually ships

This is what ejected #6938 from the merge queue at 20:20, and it was a real failure, not the
conflict I first blamed: `reborn_integration_skill_activate` asserted main's listing-header text
verbatim, including "at most four active skills total per run". This PR rewrites that header and
raises the cap to eight, so the assertion could only fail.

It never ran on the PR lane. `tests/integration/` is the Reborn integration tier, which the merge
queue runs and `pull_request` does not — so every local and PR check was green while the queued
merge commit failed. That is the same blind spot as the `--all --lib --bins` clippy lane: a whole
class of check that only exists at merge time.

The assertion now pins the shipped wording, keeping its intent (deliberate model-invoked selection):
the `builtin.skill_activate` opener, "activate it FIRST … instead of your own default approach", the
eight-skill cap, the do-not-activate-adjacent rule, and the ambiguous-name instruction.

Verified: `reborn_integration_skill_activate` 20/20, plus golden_payload, greeting,
surface_disclosure and tool_disclosure green — the other suites that read prompt text.

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

* fix(skills): follow main's ProcessBackendKind::TenantSandbox -> UserSandbox rename

CI failed to COMPILE this PR's `multi_tenant_skill_scripts_cannot_execute` test: main renamed the
variant (keeping `#[serde(alias = "tenant_sandbox")]` for wire compatibility, which does nothing for
a Rust path). Five references updated, prose included.

My local build was green on the same commit because GitHub tests `refs/pull/6938/merge` — this
branch merged with CURRENT main — while I had only checked my branch against the main I last merged.
Merged main again so local and CI look at the same tree.

`cargo check --workspace --all-targets` clean; the test itself passes 2/2.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 15:25:33 +00:00
jinxin
9edf3fe394 test(inspector): add browser, security, and operator coverage (#7280)
* feat(inspector): add operator inspection API

* docs(inspector): assign product service ownership

* test(inspector): ratchet diagnostic contracts

* feat(inspector): add debug panel shell

* test(inspector): cover debug panel shell e2e

* fix(inspector): stop diagnostics when panel closes

* feat(inspector): add prompt inspection

* fix(inspector): follow current webui ownership

* feat(inspector): add model call statistics

* test(inspector): cover model statistics e2e

* fix(inspector): avoid uncollected tool metrics

* test(inspector): cover prompt diagnostics e2e

* test(inspector): align statistics e2e scope

* fix(inspector): redact prompt metadata

* fix(inspector): preserve per-call model identity

* fix(inspector): classify prompt instruction sources

* test(inspector): assert reported token usage

* feat(inspector): add activity timeline and turn navigation

* test(inspector): cover activity timeline in browser

* fix(inspector): read current run before publishing activity

* feat(inspector): add bounded tool execution details

* test(inspector): cover bounded tool details in browser

* fix(inspector): validate retained tool result sizes

* test(inspector): add security and operator coverage

* test(inspector): cover browser workflows end to end

* fix(inspector): address review feedback

* fix(inspector): retry transient snapshot failures

* fix(inspector): address prompt diagnostic review findings

* fix(inspector): follow debug query navigation

* fix(inspector): preserve stream terminal state

* fix(inspector): capture full capability surface

* fix(inspector): scope projection activity to its run

* fix(inspector): harden activity diagnostics

* fix(inspector): bound tool result diagnostic capture

* fix(inspector): harden tool diagnostic pipeline

* fix(inspector): address prompt diagnostic review feedback

* fix(webui): harden inspector stream coverage

* fix inspector model call stats review findings

* fix inspector refresh and truncation regressions

* fix(inspector): address activity timeline review feedback

* fix(inspector): harden activity lifecycle handling

* fix(composition): move tool diagnostics to loop host

* fix(inspector): address review findings
2026-08-08 13:13:52 +00:00
Benjamin Kurrek
5f7b5d3a5f test(disclosure): measure the Core delivery pair in the wide-catalog benchmark (#7393)
The representative fixture omitted the two tools this PR moved to Core,
so the reduction floor could not see their standing advertised cost.
Model both (a bounded deliver call, a no-argument lister), and re-pin
the recorded baseline 82.9% -> 82.0% per the constant's own
instructions — cause: core-set width plus the fixture change.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:01:07 +00:00
Benjamin Kurrek
a275895cc0 fix(live-qa): verify triggered Slack delivery through the two-lane contract (#7389)
* fix(live-qa): verify triggered Slack delivery through the two-lane contract

Since #7157 a triggered fire's result is never pushed by the completion
driver: the fire itself calls builtin.outbound_deliver, and the
background-run notifier's triggered-run-delivery record describes NOTICE
delivery only — a cleanly completed fire records `skipped`. The delivery
cases still required that record to say `delivered`, which no longer
exists for results, so qa_3d/qa_8d/qa_9b/qa_9d hard-failed every
scheduled run from the first post-#7157 canary (2026-08-08 00:24 UTC)
even though all four live fires verifiably delivered (three had the
marker sitting in Slack history; the fourth was provider-confirmed).

The waiter now verifies what the product actually guarantees:

- success = the fire's durable outbound/deliveries model-delivery record
  for the exact run (delivered, expected DM) PLUS the independent Slack
  history read-back finding the marker;
- notifier records: `skipped`/`no_default_configured`/`delivered` are
  healthy terminals, only `failed`/`denied` fail the case, and unknown
  future vocabulary surfaces through timeout diagnostics;
- a completed outbound_deliver whose composed content lacks the marker
  fails deterministically (the qa_8d mode: the stale prompt bound the
  marker to the final answer, which is no longer the delivered payload);
- the readback-inconclusive flake classification accepts an
  exactly-one-verified-send through either lane.

Case prompts now bind the marker to the delivered Slack message itself
(and still to the final answer), via one shared prompt-requirement
helper.

Also fixes the QA 6D-6E strict-scrub false positive: progressive tool
disclosure (#6958) records tool_search output in traces, and the
builtin.extension_register_hosted_mcp description's "bearer for a static
API token or PAT sent as a Bearer token" prose tripped the bearer
pattern, deleting the trace and failing the shard with all cases green.
The bearer pattern now requires 16+ token-alphabet characters.

All delivery-wait decision logic is pinned by new unit tests against the
production record shapes captured from the failing canary artifacts.

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

* fix(live-qa): close review findings on the two-lane delivery contract

- Gate marker_deliver_count on completed previews: a failed or in-flight
  outbound_deliver whose content carries the marker never reached Slack,
  and counting it could fake the exactly-one-verified-send inconclusive
  classification or suppress the deterministic markerless red. Fixture
  gains a failed marker-bearing preview, observed red before the fix.
- Align emit_results_json.py's bearer pattern with the scrub script's
  16-char floor so description prose in results.json is not mangled to
  "Bearer <REDACTED>"; prose-preservation regression added.
- Namespace the readback-inconclusive evidence per lane
  (vendor_evidence/deliver_evidence) — both dicts carry
  parse_error_count and the flat merge let one overwrite the other.
- Reuse the production root_filesystem schema helpers in the new test
  fixtures instead of a hand-written CREATE TABLE.
- Document why the deterministic content check keys on
  skipped/no_default_configured rather than the whole healthy_terminal
  class: `delivered` includes a fire parked on an approval gate whose
  run resumes — and may deliver — after the notice.

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

* test(live-qa): pin the exact 16-char bearer floor on both redaction rules

The prose-preservation tests prove prose survives but not the threshold
itself — a {15,} regression would have passed both. Pin the 15/16
boundary explicitly in the emitter suite and the shell scrubber suite,
since the two rule sets are documented as kept in sync.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 13:00:21 +00:00
Benjamin Kurrek
a8ea9f3a1b test(telegram): pin reply anchoring and overlap busy-notice feedback (#6643, #6644) (#7364)
* test(telegram): pin reply anchoring and overlap busy-notice feedback

Issues #6643 and #6644 reported Telegram messages vanishing and replies
misattributed to the wrong prompt. Investigation showed both are already
fixed on main: replies carry reply_to_message_id of their prompting
message, a working indicator posts while a run is live, and a DM arriving
mid-run gets an immediate anchored busy notice. Nothing pinned that
contract, so it could silently regress. Pin it end-to-end through the
production webhook, admission, observer, and wire evidence.

Related #6643, #6644

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

* test(telegram): prove the busy notice lands mid-run and pin every anchor

Review findings on #7364: the busy notice was only observed after the
first run released, so a regression deferring it to run completion would
still pass — poll for it while the paused gateway holds the run open.
The working indicator's anchor was unasserted — require exactly one
indicator and one final reply anchored to the first message, one busy
notice anchored to the second, and no other anchors in the race chat.

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

* test(telegram): cover the bot-targeted /pair repair re-send

Review finding on #7363: the alias leg drove only plain /pair CODE while
the PR claims the bot-targeted form works via the generic @bot strip. Pin
the composition directly: a bound sender re-sending a fresh code as
/pair@bot CODE is serviced as the idempotent repair path and receives the
already-paired notice, with the pairing left connected.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: firat.sertgoz <firat.sertgoz@near.ai>
2026-08-08 12:35:15 +00:00
firat.sertgoz
78b1b6b126 feat(webui): add audited admin thread scraping (#7228)
* feat(webui): add audited admin thread scraping

* docs(webui): assign thread scraping handlers

* fix(admin): harden thread scraping review paths

* test(admin): cover thread scrape target reset

* test(admin): require scrape request before reset

* fix(ci): assign thread scraping views to artifact owner

* fix(review): address thread scraping follow-ups

* fix(webui): ignore cancelled thread list updates

* fix(webui): redact thread scraping errors

* fix(webui): translate scraping errors at render

* fix(webui): keep scraping copy out of chat entry

* fix(webui): preserve lazy admin locale parity

* docs(reborn): map thread scraping owners

* fix(webui): invalidate stale trajectory downloads

* fix(webui): address BenKurrek review — thread scrape gates, audit safety, tests (#7228)

* fix(webui): close remaining review items — gate coverage, cursor precedence, run aborts, windowed transcript, react-query data layer (#7228)

* style(webui): rustfmt the merged route pattern list
2026-08-08 12:28:43 +00:00
Benjamin Kurrek
a443a5f421 fix(ci): recapture extension_host coverage floor + run goldens on prompt-surface PRs (#7371)
* fix(ci): schedule the golden lane for prompt-surface production changes

A production change to the model-visible prompt surface (the capability
surface digest, the instruction bundle, the communication-context
renderer, or a shipped loop-tier prompt asset) ran only crate buckets on
the PR lane, so stale golden_payload snapshots surfaced first as a
merge-queue bounce (#7361, 2026-08-07: surface.rs changed the surface
digest; the PR lane never ran the golden bucket). Add a curated
prompt-surface owner table that ADDITIONALLY schedules the golden
integration lane without consuming the path's normal package
classification. Self-tested per entry plus a negative control pinning
that ordinary production changes keep the narrow plan.

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

* fix(ci): recapture the extension_host coverage floor with measured wobble tolerance

The 2026-08-04 entry was an adjustment whose own text ordered the next
real recapture. Since then code left the crate (denominator 24529 ->
24415) while the ratio ROSE to 88.12%, and the same commit df90072c4e
measured >=21560 covered lines in the merge-queue lane but 21515 twice
on the push lane — a >=45-line same-commit spread over a 20-line
tolerance, redding main on noise (run 31208592262). Recapture both
fields from that run's own gate output and size tolerance_lines to the
measured cross-lane wobble.

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

* fix(ci): cover the host-managed prompt composer and derive per-entry tests

Review findings on #7371: the host_managed_ports pair (prompt.rs drives
the InstructionBundleBuilder, model.rs shapes the pinned request) was
missing from the prompt-surface table — the exact gap class the mapping
exists to close. And the self-test enumerated entries by hand, so a new
entry could ship untested. Add the host_managed_ports prefix and derive
the positive cases from the tables themselves; an entry whose crate the
fixture lacks now fails the suite explicitly instead of skipping.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 12:26:41 +00:00
ironclaw-ci[bot]
7be9b840c0 chore(agents): refresh codebase knowledge graph (#7388)
Co-authored-by: github-merge-queue <118344674+github-merge-queue@users.noreply.github.com>
2026-08-08 07:00:34 +00:00
Benjamin Kurrek
9f71cbbb9e fix(loop): make routine delivery steering deterministic under disclosure (#7390)
A web-created routine asking for GitHub-issue summaries "in a Slack
message" was created with a stored prompt instructing the fire to use
the vendor send-message tool instead of a pinned
builtin__outbound_deliver step; an identical retry produced the correct
pinned step. Two compounding causes, both observed live:

1. builtin.outbound_deliver and builtin.outbound_delivery_targets_list
   were Discoverable-tier, so on a catalog past the defer threshold the
   bridged disclosure surface (default since #6958) drops them from
   visible_capabilities — and the delivery guidance block renders only
   while both are visible (delivery_tools_visible). trigger_create is
   Core, so the model could create routines while blind on the delivery
   lane and without the "'Send it to me' is bot delivery via
   builtin__outbound_deliver" steering. Whether the steering existed
   depended on whether an earlier tool_search happened to disclose the
   pair. Both tools are now Core, restoring #7157's guidance-iff-tools
   coupling as a deterministic fact. The wide-catalog reduction
   benchmark is unchanged (82.9%): its synthetic fixture carries no
   outbound tools.

2. The trigger_create description and its prompt-field schema said
   "never call builtin__outbound_deliver in a web-app-created routine".
   The clause is correct for the no-named-destination default, but
   creation turns over-apply it — the qa_8d canary creation verbatim
   reasoned "I'm in the web app, so there's no outbound delivery target
   to pin — let me use the Slack extension's tools for the send step"
   before recovering. Both texts now scope the no-delivery default to
   "no external destination named" and state the named-destination rule
   explicitly: reaching the user or anyone else on an external surface
   goes through builtin__outbound_deliver with a pinned target id,
   never through integration messaging tools (concrete extension names
   kept out per the specificity gate).

Regression tests: the core-name census pins both tools with their
capability ids; the description tests pin the scoped clause, the
absence of the categorical never-clause, and the named-destination
steering on both the tool description and the prompt-field schema.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 06:59:50 +00:00
jinxin
92a2549619 feat(inspector): add bounded tool execution details (#7279)
* feat(inspector): add operator inspection API

* docs(inspector): assign product service ownership

* test(inspector): ratchet diagnostic contracts

* feat(inspector): add debug panel shell

* test(inspector): cover debug panel shell e2e

* fix(inspector): stop diagnostics when panel closes

* feat(inspector): add prompt inspection

* fix(inspector): follow current webui ownership

* feat(inspector): add model call statistics

* test(inspector): cover model statistics e2e

* fix(inspector): avoid uncollected tool metrics

* test(inspector): cover prompt diagnostics e2e

* test(inspector): align statistics e2e scope

* fix(inspector): redact prompt metadata

* fix(inspector): preserve per-call model identity

* fix(inspector): classify prompt instruction sources

* test(inspector): assert reported token usage

* feat(inspector): add activity timeline and turn navigation

* test(inspector): cover activity timeline in browser

* fix(inspector): read current run before publishing activity

* feat(inspector): add bounded tool execution details

* test(inspector): cover bounded tool details in browser

* fix(inspector): validate retained tool result sizes

* fix(inspector): address review feedback

* fix(inspector): retry transient snapshot failures

* fix(inspector): address prompt diagnostic review findings

* fix(inspector): follow debug query navigation

* fix(inspector): preserve stream terminal state

* fix(inspector): capture full capability surface

* fix(inspector): scope projection activity to its run

* fix(inspector): harden activity diagnostics

* fix(inspector): bound tool result diagnostic capture

* fix(inspector): harden tool diagnostic pipeline

* fix(inspector): address prompt diagnostic review feedback

* fix inspector model call stats review findings

* fix inspector refresh and truncation regressions

* fix(inspector): address activity timeline review feedback

* fix(inspector): harden activity lifecycle handling

* fix(composition): move tool diagnostics to loop host

* fix(inspector): cancel stale tool detail requests

* fix(inspector): address bounded tool detail review

* fix(inspector): validate tool detail responses
2026-08-08 06:08:41 +00:00
Josh Ford
6dccbfbcbd docs: fix live drift in extension, responses API, and channel docs (doc-truth PR 1/5) (#7375)
* docs: fix live drift in extension, responses API, and channel docs

The public tutorial taught the retired manifest v2 authoring format
([[host_api]] / [capability_provider.tools] / runtime_credentials), which
the v3 parser hard-rejects, and never mentioned origin_gate_matrix; the
Responses API page claimed temperature is rejected (accepted 0.0-2.0 and
forwarded), claimed model must be "default" (any well-formed name <= 256
bytes), claimed max_output_tokens is rejected (accepted and ignored by DTO
policy), and omitted the required model field from every request example;
the channel tutorial pointed at two files that no longer exist.

- docs/extensions/building-a-tool.md: rewrite manifest sections to the v3
  [[tools]] / [[tools.credentials]] / [auth.<vendor>] shape, document
  origin_gate_matrix (origins, policies, ratchet), correct the hosted-MCP
  [mcp] section, packaging via ironclaw_extension_support package modules,
  and v3 test references; drop the nonexistent script runtime kind.
- docs/api/responses.mdx: correct model/temperature/tools/tool_choice
  rejection rules, document unknown-field tolerance, add the required
  model field to all 15 request examples.
- docs/channels/building-a-channel.mdx: replace dead
  crates/ironclaw_first_party_extensions + available_extensions.rs
  registration instructions with the current package-directory mechanism.
- docs/reborn/contracts/extensions.md: state that production manifests
  author v3 (lowering into the v2 resolved model described there); label
  the v2 examples as legacy.
- docs/reborn/how-to-port-tool-to-reborn.md: superseded banner pointing at
  the v3 guides.

Part of #7317 (doc-truth pipeline, PR 1 of 5).

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

* docs: address Copilot and CodeRabbit review on doc-drift PR

- responses.mdx: tool_choice is rejected only without external-tools wiring;
  with external tools enabled it passes validation and is currently ignored
  (validate_responses_supported_fields_with_external_tools never checks it).
- building-a-tool.md: clarify that effect-derived host ports are validation
  vocabulary against the HostPortCatalog allowlist; adapters are built by
  host-runtime services after authorization/obligations, never from manifests.
- how-to-port-tool-to-reborn.md: mark the decision tree's RuntimeKind targets
  historical (v3 accepts only wasm|first_party; MCP is top-level [mcp];
  process/CLI work is the sandbox lane).
- building-a-channel.mdx: document the user install flow — virtual package
  root /system/extensions/<id>/manifest.toml, ironclaw extension search /
  install <extension-id> (ID, not path), WebUI Extensions lifecycle.

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

* docs(responses): align the limits bullet with the corrected tool_choice claim

The rejection list was corrected in the previous commit (tool_choice is
rejected only without external-tools wiring); the "Limits and quirks"
bullet still said "not supported ... rejected with 400". Same claim, one
wording.

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

* docs: apply verified code-review findings on the drift PR

A full code review of this PR against live code surfaced claims the
original drift pass got wrong or missed; every fix below was re-verified
against the cited source before editing:

- responses.mdx: standard `ironclaw serve` deployments always wire
  external tools (OpenAiCompatRouteMountPorts requires the store/resume
  pair; mount.rs wires them unconditionally), so `tools` is accepted and
  `tool_choice` is accepted-and-ignored on shipped binaries — the
  conditional 400s apply only to custom compositions without the wiring
  (now a Note). temperature is validated and carried in the submitted turn
  payload but not applied as a provider sampling parameter. Non-streaming
  wait timeout is 30 s (DEFAULT_RESPONSES_WAIT_TIMEOUT), not 120. usage on
  retrieval is read best-effort from persisted run state incl. USD cost
  (read_run_usage), not always zero.
- building-a-tool.md: the [auth.example] oauth2_code recipe gains the
  required token_response map (deny_unknown_fields rejects the example as
  previously written); Gmail/Google Calendar corrected to first_party
  runtimes (their manifests declare kind = "first_party"); the worked
  api_key recipe is github's, not slack's; the tail "Quick implementation
  checklist" and reference list were still v2-era (script lane,
  assets/<extension>/ path, "manifest v2", v2.rs pointer) and now teach
  the v3 shape; composition/CLI package-naming claim narrowed (the binary
  does link slack/telegram adapter crates).
- contracts/extensions.md: legacy-format paragraph no longer claims
  host-bundled packages ship v2 (none do), and origin_gate_matrix is
  attributed to capability.rs + building-a-tool.md instead of
  extension-runtime/overview.md §3, which does not mention it.
- how-to-port banner: `script` manifest authoring is retired; the
  RuntimeKind::Script symbol survives as the process-sandbox lane's kind.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 04:57:12 +00:00
Henry Park
cae1a04f95 feat(sandbox): add explicit Docker and Railway user sandbox profiles (#7214)
* feat(sandbox): add Docker and Railway user sandbox profiles

* fix(sandbox): close local review gaps

* test(sandbox): cover production local profile wiring

* fix(ci): schedule sandbox paths and classify tests

* fix(sandbox): bound Railway user lifecycle state

* test(cli): cover sandbox profiles in profile list

* test(architecture): shrink composition support debt

* fix(sandbox): harden Railway lifecycle review gaps

* fix(sandbox): fail closed on Railway provider errors

* fix(sandbox): close remaining review gaps

* test(sandbox): remove Railway timing flake

* chore(composition): record sandbox assembly budget

* fix(sandbox): retain Railway parse causes

* fix(sandbox): address post-merge review findings

* fix(sandbox): close railway lifecycle review gaps

* fix(sandbox): retain failed railway cleanup state

* feat(sandbox): enable direct egress for sandbox profiles

* test(sandbox): keep public egress in live canary

* chore(sandbox): document bounded slice safety

* test(architecture): ratchet user sandbox contract growth

* test(sandbox): strengthen profile regression coverage

* test(sandbox): exercise harness validation at boundary

* test(sandbox): place setup check with harness contracts

* fix(sandbox): preserve nonzero Docker exit results

* fix(host-runtime): resolve hosted tenant workspace mounts (#7214)

* fix(sandbox): address review feedback on bounded startup (#7214)

* fix(ci): derive sandbox Docker paths from crate inventory (#7214)

* fix(sandbox): finalize Railway lifecycle and profile parity

* test(sandbox): update hosted process failure contract

* fix(sandbox): address CodeRabbit review feedback (#7214)
2026-08-08 00:18:56 +00:00
firat.sertgoz
254483d219 test(disclosure): pin wide-catalog schema-token reduction floor and make drift visible (#7372)
* test(disclosure): pin wide-catalog schema-token reduction and make drift visible

The representative 91-tool disclosure benchmark asserted only the 50% floor
from #6810 and printed its table, so a slow slide in advertised schema cost
(a widening Core tier, a growing bridge schema) could eat most of the
headroom without any test noticing.

Extend the existing benchmark test rather than adding a second one:

- keep the 50% floor as an explicit, message-carrying assertion;
- pin the currently measured 82.9% reduction with a +/-2 point drift band, so
  material movement fails loudly and forces an explanation;
- record the drift history (93.3% bridge-only -> 83.7% -> 82.9%) next to the
  baseline constant;
- attribute advertised tokens to bridge vs Core tier in the emitted line, so a
  failure names its cause.

Test-only; no production behavior changes.

Refs #7166 (section 5, wide-catalog benchmark acceptance item).

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

* test(disclosure): attribute benchmark tokens by ToolTier, not name exclusion

The advertised-cost breakdown treated every non-bridge definition as Core, so
a promoted Discoverable tool reaching the advertised surface would have been
charged to Core and misdiagnosed the drift it caused.

Classify each advertised definition by the catalog entry's actual
`ToolTier`, report promoted Discoverable tokens as their own bucket, and
assert that bucket is empty — the benchmark promotes nothing, so a
Discoverable tool appearing there means the selection path changed and should
fail loudly rather than hide inside the Core figure.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:53:06 +00:00
Benjamin Kurrek
90f5532fcb feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted (#7157)
* feat: explicit channel delivery tool — two lanes, notification channels, delivery heuristics deleted

Re-landed PR #7157 on current main (aa8b748c3967f33aa4, across the WS2
composition inversion, the WS6 crate renames, and the WS7 family moves), with
all 44 review comments dispositioned.

Two-lane delivery model: a run's final reply always lands in its own
conversation (lane 1); reaching any other surface is the model's explicit
`builtin.outbound_deliver` call (lane 2 — bot identity, one catalog target
per call, synchronous through the DeliveryCoordinator, provider-issued
message refs as evidence). Background-run notices fan out to a user-configured
notification-channel set (new record field + read-side legacy migration,
`builtin.notification_channels_set`, first-approve-wins, WebUI multi-select).
The stored delivery heuristics are deleted (route_current, builtin:web_app,
outbound_delivery_target_set, per-trigger delivery_target_id + precedence
chains + four-slot preference fallback), with an idempotent boot migration of
stored trigger targets into explicit prompt steps and the retired vocabulary
pinned in reborn_retired_taxonomy.rs.

Port notes (old → new homes): ironclaw_reborn_composition→app/ironclaw_composition,
ironclaw_product→product/ironclaw_assistant, first_party_extensions→extensions/packages,
run-profile vocabulary→ironclaw_loop_contracts, PreferenceTargetCodec→
ironclaw_extension_contracts, wire DTOs→ironclaw_product_contracts::product_wire.
The model-delivery implementation moved extension_host→assistant
(CoordinatedModelChannelDelivery) — the WS2 port inversion forbids extension_host
naming product types; the deferred-slot registration and post-coordinator bind now
live in composition's production assembly, mirroring TriggeredRunDeliveryDriver.

Re-folded 2026-08-05 onto b2023bc8f (#7258): channel-adapter vocabulary
re-imported from ironclaw_extension_contracts, product-adapter/inbound
vocabulary from host_api/product_contracts, module-charter map's outbound
row renamed to the two-lane vocabulary. Post-branch CI gates adapted in
the same change: skills/ classified in the PR test planner (test-first,
sabotage-verified), panic baseline ratcheted down, nested test fixtures
renamed to the scanner-sanctioned support_tests.rs shape, composition's
inline trigger-migration tests split to tests.rs (mass budget green with
no ceiling raise), extension_contracts size ceiling 7727 -> 7748 (+21:
the ActivePreferenceTargetCodecs port), loop_contracts ceiling
re-captured down 14479 -> 13850 after the delivery-vocabulary deletion.

Third fold 2026-08-05 onto b72d7da66 (#6831, standardized messaging
framework): the two-lane guidance moved into the canonical messaging core
prompt (host_api prompts/messaging/send_message.core.md), now naming
builtin__outbound_deliver with the arrive-twice and trigger caveats for
every messaging extension; slack vendor addendum/manifest taken as #6831
shipped them; ceiling-table union (host_api 18570 beside this PR's two
re-captures); retired slack schema embed and deleted preferences
capability stay deleted; golden context-surfacing snapshot regenerated
(one surface-hash line).

Fourth fold 2026-08-06 onto c69ed2d70 (#7263 program-closure batch +
sibling fixes): ceiling-table union (product_contracts 15685 from #7230
beside this PR's re-captures) and main's tracing-target syntax sweep
(target = -> target:, gate-enforced) applied over this PR's kept lines;
deleted delivery-heuristic code stays deleted.

Fifth fold 2026-08-06 onto 0c297cb24 (#7264 guidance-layer sweep):
zero conflicts; guidance/doc-pointer changes auto-merged over this delta.

Routing-UX slice 2026-08-06 (product thread + follow-ups): result routing
is prompt-owned with a pinned source-surface default (bare "send me" =
the surface you asked from; web app = no delivery step; explicit
destinations override, one delivery step each) — iterated against live
recordings until a real model followed it, with two live-recorded QA
fixtures (bare-webui, multi-channel) plus contracts and replays. The
automations-page panel is retained as the notification-channel selector
(notices only); the conversational notification_channels_set tool writes
the same validated set.

Delivery-evidence fix (theredspoon's flag; #7029 fixes the same swallow
on main): mark_terminal reports whether the durable write committed and
a confirmed send whose Delivered row failed to commit returns
DeliveredUnconfirmed (refs retained, durably_recorded: false), never a
fabricated Delivered — regression-tested and sabotage-verified. Plus a
CodeRabbit triage batch: correctable coordinator errors stay
model-visible, omitted target_ids no longer clears the set, the success
schema requires evidence, the composition outbound facade is dissolved,
and guidance/contract docs are aligned.

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

* fix: harden channel delivery routines and replay

* fix: preserve automation loading and identity freshness

* fix: close channel delivery review defects

* fix: skip paused routine catch-up slots

* ci: record channel delivery composition budget

* test: align composition baseline with channel delivery

* fix(auth): survive interrupted OAuth callbacks

* fix(auth): keep callback coordination panic-free

* fix(ci): reconcile channel delivery merge seams

* fix(ci): recapture merged contracts ceiling

* fix(delivery): close review findings across delivery, migration, and guidance

Fixes the findings from the multi-agent review of this PR. Every behavioral
fix ships with a regression test that fails before it.

CI (red on this head)
- `standalone_yolo_notification_channels_set_bypasses_approval_gate`
  expected the shared "invalid outbound delivery request" summary for a
  `builtin__notification_channels_set` call. Production deliberately
  specializes that message per operation and pins it with
  `notification_channel_failure_names_the_operation_the_model_can_correct`;
  the assertion was the stale side.

Delivery evidence (kernel + assistant + outbound)
- `AlreadyDelivered` replays reported `delivered: false` "unverified"
  because the ledger row retains no provider refs, inviting the duplicate
  resend the at-most-once claim exists to prevent. Evidence gained
  `already_delivered`; a replay now reads as delivered with an honest
  "not resent" summary. The classification suite had no `AlreadyDelivered`
  case at all, which is why this shipped.
- `DeliveredUnconfirmed` is the one non-`Delivered` outcome that actually
  sent something, but `delivered_messages_from_outcome` dropped its refs,
  so gate reply-routes went unrecorded and a live OAuth prompt could never
  be retracted on that path.
- `content` is now rejected when empty: the input schema advertised
  minLength 1 and nothing enforced it, so empty content reached the channel
  as an empty part and returned an opaque provider error.

Background-run notifier (assistant)
- One run legitimately emits several `RunBlocked` notices (re-auth
  stand-in, unserviceable-auth cancellation, run failure), but all three
  derived the same projection ref, and the delivery id hashes it. The
  second notice to a target came back `AlreadyDelivered`, was treated as
  success, and was never sent — a user could be told a routine needed
  re-authorization and never told it then failed. Notices carry a
  discriminator; once-per-run kinds keep their historical id shape, so
  existing delivery identities are unchanged.
- When every catalog lookup failed, the empty result was recorded as
  `NoDefaultConfigured`, reporting a backend outage as the benign "user
  configured nothing" state. It now records `Failed`.

Boot migration (composition)
- The retired `builtin:web_app` target meant "no external delivery". It was
  being rewritten into a delivery step to an id nothing can resolve,
  inverting the stored intent on every later fire. It now clears without
  adding a step.
- One unmigratable row aborted the entire composition boot, with the error
  telling the operator to shorten a prompt through the UI that no longer
  starts. It now pauses its own routine — a paused trigger cannot fire, so
  "never fire unrouted" still holds per record — and boot continues. Only a
  systemic store failure stays boot-fatal. A row deleted during the CAS
  retry ends that record instead of failing boot.
- The CAS retry loop, its bounded exhaustion, and the vanished-row arm had
  no caller-level coverage; adds a delegating repository double that forces
  CAS misses. The prior fail-closed test is rewritten to pin the invariant
  it documented (route survives, record not half-migrated) under the new
  per-record mechanism.

Model-visible messages (composition)
- The targets-list denial said "not permitted to change the outbound
  delivery target" for a read-only call, and the lease denial named the
  retired delivery-target concept on the notification-channel path that is
  its only production caller. Both are now operation-specific and pinned.

WebUI (frontend)
- `setNotificationChannels()` with no argument posted `target_ids: []`,
  turning an omitted argument into a destructive clear-all and defeating
  the backend contract that deliberately rejects an omitted field.
- The notification-channels panel stayed editable after a failed read, so
  toggling one row full-replaced the stored set from an empty baseline and
  silently dropped every channel the user never saw. Editing is now locked
  on a failed read, with a rendered explanation.
- Adds the missing `tools.description.builtin.notification_channels_set`
  key to all 11 locales, plus save-failure coverage for the hook (which was
  correct, but untested) and locale-parity tests.

Guidance
- The new `.claude/rules/tools.md` was ported from a pre-restructure branch:
  it named `ironclaw_dispatcher` (deleted) and `ironclaw_extensions` (never
  existed), and its review command grepped three paths removed by WS6/WS7.
  Its `paths:` frontmatter also never matched the product/composition
  callers its rules govern, so the rule never loaded for them.
- `ironclaw_loop_contracts` now records both embedded prompt assets; this
  PR added a second one while the crate's Known-debt entry still said one.
- Bumps `skills/delegation` (rewritten guidance, unlike its two siblings in
  this PR which both bumped) and fixes a pre-rename path in the
  extension-runtime checklist.

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

* fix(delivery): unify the DM-rule enforcement point and re-ratchet composition

CI (composition mass budget, red on the previous head): the per-record
migration quarantine pushed composition 6 LOC over its absolute ceiling.
Resolved by the reduction the budget file itself blesses rather than a
raise — `runtime/approval.rs`'s 417-line inline `#[cfg(test)]` module split
verbatim into `runtime/approval/tests.rs` (the gate excludes test-only
files but counts inline test modules). Composition is now 40,432 LOC,
smaller than before these fixes, and `loc_ceiling`/`loc_observed` plus the
arch-test record are re-captured together at the measured value per the
gate's one-directional ratchet rule.

The codec-scan that decodes a binding and enforces "an OAuth authorization
URL only ever lands in a personal DM" existed twice — once in
`TriggeredReplyTargetAuthority`, once as `CodecChannelTargetResolver` —
with both copies commented as "the single enforcement point". They are now
one implementation, shared by the notifier and `builtin.outbound_deliver`,
with a context label so each path keeps its own diagnostic.

That rule turned out to be UNGUARDED: sabotaging it (`if false && ...`)
failed no test in the crate. The vendor codecs pin the predicate in
isolation and the coordinator test pins rejection handling with a double
that decides the verdict itself, so nothing covered the wiring that joins
them. Adds a contract test driving the real resolver through
`DeliveryCoordinator::deliver` for both verdicts, asserting a non-DM target
never reaches the vendor adapter. Sabotage-verified: the test fails with
the rule disabled and passes with it restored.

Smaller findings: the notification-channel schema cap now derives from
`ironclaw_outbound::NOTIFICATION_TARGETS_CAP` instead of hand-mirroring
`8`; `triggered_run_delivery`'s module and trait docs described the retired
result-push model this PR deletes; the two new notification strings used a
different brand spelling and dash style from the nine siblings in their own
module; and several new comments navigated by pre-rename paths
(`ironclaw_product::`, `local_dev::`, `crates/ironclaw_webui/`) plus a
citation of a test symbol that does not exist.

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

* fix(outbound): scope the delivery catalog to the authenticated actor

`builtin.outbound_deliver` resolved its destination catalog under
`ResourceScope.user_id` while performing the send as
`authenticated_actor_user_id`. Those are the same user on a personal thread
and on an automation fire, but they diverge on a shared-route channel
conversation: the scope user is the route's SUBJECT
(`TurnScope::explicit_owner_user_id`) and the actor is whoever sent the
message. Any participant of such a channel could therefore name the
subject's target ids and push bot-identity content into the subject's own
destinations — their personal DM included — from a conversation the subject
may never read.

The catalog now follows the actor, so a caller stays inside their own
connected surfaces on every path and an unfamiliar target simply does not
resolve. Behavior is unchanged wherever owner and actor already agree,
which is every non-shared-route path.

Regression test drives the divergent case through the port (participant
denied with `TargetUnavailable`, nothing reaching a vendor adapter) plus a
control proving the owner's own delivery still works. Sabotage-verified:
restoring owner-scoping fails it.

NOT changed here, and flagged for a product decision: the sibling
`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derive their caller from the same
owner-preferring `effective_user_id`, so on a shared route a participant
can still enumerate — and, with the approval gate auto-approved, rewrite —
the subject's notification channels. That helper also scopes approval
gates and capability leases, so flipping its precedence risks breaking
approval raise/resume matching in a path no test covers; it needs its own
change with that coverage.

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

* fix(delivery): stop rewriting the DM-target row on every message

The post-admission backfill calls `FilesystemChannelDmTargetStore::upsert`
for every admitted inbound direct message, and the store unconditionally
wrote a fresh row. After the first message the stored record is already
correct, so the steady state was one durable backend write per DM message,
forever, whose only effect was a new `updated_at` — and each message's
reply-delivery observation was serialized behind it. An unchanged record
now short-circuits; the existing row is loaded here anyway to preserve
`created_at`, so the comparison costs nothing.

Also adds the regression test the `NoDefaultConfigured` -> `Failed`
classification fix landed without: the notifier's `SkipEntry` lookup lane
had no coverage at all (no test ever made a catalog lookup error), so
neither the skip nor the all-failed arm was exercised. The triggered
harness gains an injectable catalog provider for it. Sabotage-verified.

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

* fix(loop): bound the connected-channels line so the runtime slice fits

Confirmed live, not theoretical: a worst-case runtime context renders 4,391
bytes against the 4,096-byte `PromptTextSurface::SafeSummary` cap that
`instruction_bundle::push_runtime_context` validates the whole slice on —
and exceeding it is a run-ending error on EVERY prompt build for that user,
not a one-off.

This PR's fixed ~1.1 KiB delivery-guidance block is what pushes a
previously-fitting context over. The individual parts are each bounded
(location 200 chars at its producer, locale 35, per-label safe-text
validation), but nothing bounded their SUM, and the connected-channels line
is the one part that grows without limit: up to 20 entries whose names and
presentation hints are only individually capped.

That line now renders as many channels as fit a 1 KiB budget and folds the
rest into the "+N more" counter it already carried, so the fixed guidance
can never be squeezed out by variable content. The worst-case test that
found this stays as the pin.

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

* fix(outbound): resolve outbound capabilities as the acting user

`builtin.outbound_delivery_targets_list` and
`builtin.notification_channels_set` derived their caller from
`effective_user_id`, which prefers the thread owner over the actor. Those
agree on a direct message and on an automation fire, but diverge on a
shared-route channel conversation, where the owner is the route's
configured subject — the deployment operator by default
(`channel_workflow.rs`) — and the actor is whoever posted.

So any participant of a shared channel could enumerate the operator's
connected destinations and rewrite the operator's notification-channel set,
which is where approval prompts, re-auth prompts and failure notices are
delivered. The caller now follows the acting user, matching the fix already
applied to `builtin.outbound_deliver`.

This deliberately REVERSES a previously pinned preference. Two tests
asserted the owner won when the two differ; that pin predates shared-route
subjects defaulting to the operator, and it contradicts the rule that a run
acts as whoever invoked it. Both are updated to pin the actor, with the
reversal recorded at each site rather than silently relaxed, and the
notification-channel write is now asserted to land under the acting user
with the thread owner's own set left untouched.

INTERIM, by design: `resource_scope_for_run` and `settings_scope_for_run`
still follow the owner, because they scope the approval-gate raise and the
capability lease and those must stay matched between raise and resume.
Unifying them belongs with the follow-up that removes shared-route subject
binding entirely so a shared channel runs wholly as its invoker; that needs
approval raise/resume coverage which does not exist yet. A new test pins
the split so the interim state is explicit rather than accidental.

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

* fix(ci): keep loop_contracts under its size ceiling and ratchet down

The runtime-context byte-budget fix and its worst-case pin pushed
`ironclaw_loop_contracts` to 14,032 production lines against a 13,949
ceiling. Resolved by the reduction the gate prefers over a raise:
`runtime_context.rs`'s 919-line inline `#[cfg(test)]` module split verbatim
into a `runtime_context/tests.rs` sibling, which `production_rust_files`
excludes (an inline test module inside a production file is counted; a
test-only file is not).

The crate now measures 13,115 — 834 lines below the previous ceiling and
smaller than before this review round — so the ceiling is re-captured
downward at the measured value rather than raised, per the gate's
one-directional ratchet. Count read from the gate's own failure message,
not by eye.

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

* fix(ci): chart the notification-channel handlers in the WebUI charter map

`handlers_module_charter` failed: `get_notification_channels` and
`set_notification_channels` — handlers this PR adds — had no sub-owner row
in `CONTRACT.md`'s enforced charter map, and the row they belong to still
named `get_outbound_preferences`, `set_outbound_preferences` and
`outbound_preferences_activity_id`, all deleted by this PR.

Both halves are fixed together because the gate checks both in one test:
unclaimed items first, then entries naming items that no longer exist. Only
the first had fired, so the stale half was still latent behind it.

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

* fix(ci): re-capture the loop_contracts ceiling after main's merge

Main's #7361/#7363 landed 66 lines in
`ironclaw_loop_contracts/src/instruction_bundle.rs`, which this branch picked
up when folding onto main. That put the crate at 13,181 against the 13,115
ceiling re-captured earlier in this PR.

Not growth from this PR. The gate's upward check is a hard `lines > ceiling`
with no headroom — `TOLERANCE` (400) governs only the downward
ratchet-nudge — so a ceiling captured at the exact observed value reddens
every open branch the moment anyone adds a line to that crate, including
from main. Re-captured at the measured value; count read from the gate's own
failure message.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 21:39:07 +00:00
Benjamin Kurrek
d27dba30f7 fix(extensions): chat "connect account" dead-end — already-connected signal, builtin description trust, docs (#7361)
* fix(extensions): host-bundled description trust + already-connected install confirmation

Two chat-side dead-ends from the 2026-08-07 Slack QA session (thread
e79a994f, run 251aec0b on ironclaw-qa-testing-libsql):

1. Host-bundled capability descriptions were description_trust=Untrusted,
   so the loop-tier prompt-text denylist strict-scanned compiled-in text
   and silently omitted builtin.extension_register_hosted_mcp from every
   model prompt's capability surface ("browser authorization-code flow"
   matched the "authorization" credential pattern). HostBundled is the
   only source eligible for effective FirstParty/System trust, so its
   repo-authored descriptions now cross the verified-catalog boundary
   like signature/digest-verified registry installs. Untrusted provenance
   (InstalledLocal, UserRegistered, unknown) keeps the strict scan.

2. When install-driven activation passed the credential gate because the
   caller's declared requirements were all satisfied, the response never
   said so — the model got only conditional guidance ("If WebChat shows
   an account connection panel...") and deflected an explicit "connect
   account" request to the web interface even though the account was
   already connected. The install response now appends an explicit
   already-connected confirmation exactly when declared requirements
   were verified present for the calling user.

Regression tests: manager surface test pins VerifiedCatalog trust for all
model-visible lifecycle capabilities through the real host runtime;
instruction-bundle tests pin retain/omit behavior for auth-vocabulary
descriptions by trust; install-path tests pin the confirmation on the
seeded-credential path and its absence for credential-free extensions.

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

* docs(channels): chat can drive the personal half of channel connect

The onboarding and channels pages claimed "asking the agent to connect a
channel doesn't work" and that the agent "may tell you it can't help".
That describes only the operator half (registering app/bot credentials).
The per-user half has shipped since early July: extension_install runs the
same activation credential gate as the Channels card, raises the in-chat
OAuth connection panel when the account is unconnected, and (as of the
sibling fix) confirms when it is already connected.

The self-knowledge protocol makes these pages the model's authority on
IronClaw's own capabilities, so the stale claim scripted the exact
refusal QA hit ("I can't initiate the Slack OAuth flow from here") on an
account that was already connected. Correct both pages to distinguish
the operator step from the chat-drivable personal connect.

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

* docs(channels): align slack and telegram setup notes with the connect contract

The slack page's operator-step note and the telegram troubleshooting
accordion still carried the blanket "asking the agent to connect will not
work" claim the overview/onboarding correction removed — same drift,
different phrasing (review catch on #7361, plus one more instance found
by a broader sweep). Both now state the two-step contract: the operator
half stays in the web interface; after it, chat drives the personal half
(install/activate -> in-chat connection or pairing panel, or an
already-connected confirmation).

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

* test(golden): recapture surface digests over the description-trust change

The queue run failed golden_payload because the branch predated current
main and its own surface.rs trust fix changes the surface digest. The
recaptured snapshots differ ONLY in the surface sha256 lines (verified
char-by-char) — no prompt text or capability-list changes.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 19:42:57 +00:00
Benjamin Kurrek
df90072c4e fix(telegram): accept /pair as a pairing-code alias (#7363)
Users habitually type /pair from the earlier pairing flow. Keep every
suggested wording on /start (the vendor deep-link convention) and accept
/pair <CODE> as a declared inbound-code-prefix alias so those users pair
instead of looping through the connect nudge.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:37:05 +00:00
firat.sertgoz
e6a650f1fe feat(reborn): enable progressive tool disclosure by default (#6958)
* feat(reborn): enable progressive tool disclosure by default

* test(reborn): pin scripted QA tool disclosure

* test(reborn): pin composition tool surfaces

* test(reborn): pin hook runtime tool surface

* test(reborn): make flat tool fixtures explicit

* test(e2e): pin flat disclosure fixtures

* test(e2e): pin responses fixtures to flat tools

* chore(reborn): clarify tool disclosure wording
2026-08-07 17:26:23 +00:00
jinxin
d8fc8376b6 feat(inspector): add activity timeline and turn navigation (#7278)
* feat(inspector): add operator inspection API

* docs(inspector): assign product service ownership

* test(inspector): ratchet diagnostic contracts

* feat(inspector): add debug panel shell

* test(inspector): cover debug panel shell e2e

* fix(inspector): stop diagnostics when panel closes

* feat(inspector): add prompt inspection

* fix(inspector): follow current webui ownership

* feat(inspector): add model call statistics

* test(inspector): cover model statistics e2e

* fix(inspector): avoid uncollected tool metrics

* test(inspector): cover prompt diagnostics e2e

* test(inspector): align statistics e2e scope

* fix(inspector): redact prompt metadata

* fix(inspector): preserve per-call model identity

* fix(inspector): classify prompt instruction sources

* test(inspector): assert reported token usage

* feat(inspector): add activity timeline and turn navigation

* test(inspector): cover activity timeline in browser

* fix(inspector): read current run before publishing activity

* fix(inspector): address review feedback

* fix(inspector): retry transient snapshot failures

* fix(inspector): address prompt diagnostic review findings

* fix(inspector): follow debug query navigation

* fix(inspector): preserve stream terminal state

* fix(inspector): capture full capability surface

* fix(inspector): scope projection activity to its run

* fix(inspector): harden activity diagnostics

* fix(inspector): address prompt diagnostic review feedback

* fix inspector model call stats review findings

* fix inspector refresh and truncation regressions

* fix(inspector): address activity timeline review feedback

* fix(inspector): harden activity lifecycle handling
2026-08-07 16:58:21 +00:00
jinxin
81724a6859 feat(inspector): add model call statistics (#7277)
* feat(inspector): add operator inspection API

* docs(inspector): assign product service ownership

* test(inspector): ratchet diagnostic contracts

* feat(inspector): add debug panel shell

* test(inspector): cover debug panel shell e2e

* fix(inspector): stop diagnostics when panel closes

* feat(inspector): add prompt inspection

* fix(inspector): follow current webui ownership

* feat(inspector): add model call statistics

* test(inspector): cover model statistics e2e

* fix(inspector): avoid uncollected tool metrics

* test(inspector): cover prompt diagnostics e2e

* test(inspector): align statistics e2e scope

* fix(inspector): redact prompt metadata

* fix(inspector): preserve per-call model identity

* fix(inspector): classify prompt instruction sources

* test(inspector): assert reported token usage

* fix(inspector): address review feedback

* fix(inspector): retry transient snapshot failures

* fix(inspector): address prompt diagnostic review findings

* fix(inspector): follow debug query navigation

* fix(inspector): preserve stream terminal state

* fix(inspector): capture full capability surface

* fix(inspector): address prompt diagnostic review feedback

* fix inspector model call stats review findings

* fix inspector refresh and truncation regressions
2026-08-07 14:04:23 +00:00
firat.sertgoz
5888190ca6 fix(json): add bounded collection analysis (#7339)
* feat(json): add bounded collection analysis

* test(reborn): refresh JSON capability snapshots

* fix(json): address review — exact integer aggregates, bounded errors, dedup helpers (#7299)

* test(reborn): refresh reviewed JSON snapshots

* test(reborn): restore scoped JSON root query

* ci: retrigger Railway preview deploy

---------

Co-authored-by: firat <>
2026-08-07 13:00:37 +00:00
firat.sertgoz
ce2d6f84a9 fix(host-runtime): wire WASM secret-exists to staged credentials (#7307) (#7329)
Third-party WASM guests (ironhub tools such as attio) gate on the
secret-exists host import before issuing any request, but production
wired the sandbox with the deny-all default, so the probe always
returned false: attio aborted pre-network with "API key not
configured" and the host classified the plain-string guest error as
operation_failed, never auth_required.

Introduce StagedWasmHostSecrets, a per-invocation WasmHostSecrets
implementation over the staged secret injection store: exists(name) is
true exactly when authorization leased and staged non-empty credential
material for (scope, capability_id, handle), read non-destructively so
the HTTP egress still receives the material. Wire it into
WasmRuntimeAdapter::host_for_scope on every host variant and plumb the
shared store through the builder.

Credential staging now rejects empty resolved material as
AuthRequired (obligation handler and host-driven staging), so a
configured-but-blank key surfaces the typed re-auth signal instead of
an opaque guest failure. No prose heuristics: the structured
{"kind":"auth_required"} guest contract remains the fallback.

Adds unit tests for the probe semantics and WASM contract tests with a
secret-exists probe component (staged -> true, absent -> false, empty
material -> AuthRequired staging error).
2026-08-07 12:57:10 +00:00
Benjamin Kurrek
8b32989d0a Guidance unification: one canonical home per fact, a measured loader story, and a gate that keeps it true (#7306)
* ci(guidance): add check-guidance.py — guidance must reference the tree that exists

Four mechanical drift classes become build failures: every repo path named
by agent guidance (root AGENTS.md/CLAUDE.md, crates/** AGENTS/CLAUDE/
CONTRACT/README, .claude/rules/*.md, .claude/skills/*/SKILL.md) must
resolve in the tracked tree; every rules/skills frontmatter paths: glob
must match at least one tracked file (the dead-trigger class that let
skills.md never fire); every crate directory appears in its family's
AGENTS.md crate table (the guidance half of check-target-tree.py); and
every crate has a README.md (measured 62/62, so it gates).

Extraction is designed against false positives: fenced blocks, placeholder
tokens, MCP method names, dated-correction (✎) lines, and
'check-guidance: path-ok' lines are not claims; resolution honors the
citation forms measured on the live tree (root-relative, doc-relative,
name-prefix, crate-qualified-by-context, module-relative within the citing
crate). KNOWN_MISSING is a shrink-only suppression table — a row whose
reference stops dangling fails the gate until deleted, and surviving rows
print as warnings every run.

Fails closed on unreadable files, unparseable frontmatter, broken crate
discovery, and near-empty scans (floor constants). Self-test in
test-check-guidance.py (23 cases, refusals first, real repository last),
wired beside check-target-tree.py in code_style.yml; the test planner
classifies all three paths as static-control (verified exit 0).

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

* docs(guidance): consolidate crate-tier CLAUDE.md files; rename module specs to CONTRACT.md

Steps 2+3 of the guidance unification (docs/reborn/guidance-conventions.md):

- Rename the four Module Specs table specs CLAUDE.md -> CONTRACT.md (llm,
  filesystem, webui, composition), matching the identity/trust precedent.
  Charter gates repointed (llm module_charter, webui handlers_module_charter)
  and every live reference updated; pointer stubs left behind so tooling that
  loads CLAUDE.md still lands on the spec.
- Fold the nine substantive out-of-table CLAUDE.md files: wasm, mcp, sandbox,
  auth, assistant, trace_commons, extension_manager become AGENTS.md-canonical
  (gates repointed with pinned phrases kept verbatim: the wasm_sandbox_core
  arch pin, mcp module_charter, auth module_charter, assistant
  reborn_services_module_charter); network and secrets fold into their README
  Invariants sections and drop the crate guidance pair entirely.
- Mark with the convention's absence-claim annotation the five crate-tier
  lines grandfathered by check-guidance KNOWN_MISSING (llm CONTRACT.md x3,
  composition CONTRACT.md, hooks AGENTS.md) and mark trace_commons'
  prescribed tests/queue.rs mirror as prescriptive-future.
- tests/CLAUDE.md: replace the retired root Current-Limitations citation with
  the measured ironclaw_observability description.

End state: zero prose CLAUDE.md outside the Module Specs table at the crate
tier (the four ironclaw_agent_loop src/tests directory guides stay, same
footing as the tests-tree harness guides).

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

* docs(guidance): unify the root pair — AGENTS.md canonical, CLAUDE.md adapter

Step 1: root AGENTS.md (198 lines) and root CLAUDE.md (286 lines) shared zero
identical lines — the forked-pair drift the guidance convention forbids at
crate level, live at the root. Root AGENTS.md is now the canonical
tool-neutral contract (build/run/debug commands, hard invariants including
the unified extension model and the credential_name/extension_name identity
rules, the Module Specs table — now uniformly CONTRACT.md and gaining the
existing ironclaw_trust/CONTRACT.md row — testing discipline, tree map,
discovery, change discipline; 152 lines). Root CLAUDE.md is an @AGENTS.md
adapter plus the genuinely Claude-specific tail: skills/rules index,
codebase-graph MCP recipes, and the REPL info!/warn! logging rule (51 lines).

Cut while merging, each measured against the tree: the v1 Job State Machine
(no such state machine exists under crates/), Current Limitations (stale —
the observability claim no longer matches the crate), the Skills System
section (.claude/rules/skills.md and the domain crate own it), Extracted
Crates, the re-derivable key-traits list, and the long channel-onboarding
narrative (now three lines pointing at crates/extensions/AGENTS.md and the
worked slack example).

Every live citation of the root pair's moved sections is repointed (crates/
routing map + README, the deslop-reborn command, types/type-placement rules,
skills/common/config crate docs, a loop_host doc comment). The git-ignored
.codebase-memory/artifact.json mention carries the absence-claim annotation
for the check-guidance KNOWN_MISSING handoff.

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

* docs(guidance): make CLAUDE.md a symlink to AGENTS.md at every tier

The loader question is now measured, not assumed. Headless canary experiment
with a discriminating control: a symlinked nested CLAUDE.md's target content IS
injected when a file in that directory is read, an @AGENTS.md import inside a
nested CLAUDE.md also expands, and a nested AGENTS.md alone is NOT read. So one
uniform rule holds everywhere: wherever an AGENTS.md exists, CLAUDE.md sits
beside it as a symlink — same bytes, zero maintenance, no second document to
drift.

64 pointer stubs become symlinks. The four spec crates keep CONTRACT.md as
canonical; their AGENTS.md routes there, so the spec stays one hop away while
the working rules now auto-inject instead of costing a voluntary read.

Also reconciled check-guidance.py's shrink-only KNOWN_MISSING table: all 8 rows
deleted because the content pass fixed the underlying lines, and the three
absence-claims the gate then surfaced carry markers. The table is empty.

Caveat recorded for the convention: nested injection fires only below cwd, and
appears not to fire in subagent sessions — family docs must stand alone when
read deliberately.

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

* docs(guidance): amend the convention with measured loader mechanics and budgets

The first version made crate AGENTS.md canonical and CLAUDE.md a pointer, which
moved working rules out of Claude Code's auto-inject path. Records what was
measured instead: subtree CLAUDE.md injects lazily, symlinks and @imports both
carry content, nested AGENTS.md is not read natively, and injection does not
fire in subagent sessions — so every doc must stand alone when read deliberately.

Adds size budgets per tier, extends scope to .claude/rules and .claude/skills
(where the worst drift was), names check-guidance.py as the enforcement with its
suppression markers, warns that some guidance is test-parsed (including the
heading-shadowing trap), and adds the remove/rename checklist that mirrors add.

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

* fix(guidance): composition keeps a real CLAUDE.md, not a symlink

The blanket symlink pass broke composition_root_embeds_no_prompt_content, and
the gate is right to refuse: its ownership walks do not follow symlinks, so
stepping over one would let it report clean on a subtree it never read. This
crate keeps a regular pointer file, with the reason written in the file so the
next person does not 'fix' the inconsistency back into a break.

The uniform alias rule now has two stated exceptions: the root (real file, it
carries a Claude-only tail) and composition (real file, this gate).

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

* ci(guidance): enforce the CLAUDE.md alias rule; scope path-ok to the marked reference

The branch's central invariant — a `CLAUDE.md -> AGENTS.md` symlink beside
every AGENTS.md at the root and under crates/ — was unguarded: the audit
proved a committed symlink deletion left the gate green (a working-tree
deletion only tripped the accidental "cannot read guidance file" refusal).
Check 5 now judges the git index (`git ls-files -s` + `cat-file`): the
alias must be tracked, mode 120000, targeting exactly `AGENTS.md`. The two
real-file exceptions are named rows with reasons (the root adapter's
Claude-only tail; composition's symlink-refusing ownership walks), and a
row that stops matching the tree fails the gate rather than lingering.
Sabotage-verified on the real tree: `git rm --cached` on an alias went red
naming the pair; converting one to a tracked regular file went red;
restore went green (65 aliases verified).

Also from the audit:

- A `path-ok` marker now vouches for the one reference immediately
  preceding it instead of exempting its whole line — the audit slipped a
  fresh dangling path onto a marked line and passed. The `✎` glyph stays
  line-scoped by documented design. Both in-tree marker usages already
  sit marker-after-reference and keep working.
- Document the structural blind spot: a dead reference whose first
  segment died with its whole tree (the v1 `src/…` monolith) reads as
  historical narration and cannot be flagged; only review catches it.
- Re-measure the fail-closed floor comment — the shipped one claimed
  174 guidance files / ~800 references / 30 globs against a tree that
  measures 237 / ~2070 / 38 — and add a floor for alias-site discovery.

Self-test grows six cases: index-deleted alias, regular-file alias,
wrong-target alias, the load-bearing root exception row, exception rows
matching reality, and the marker-narrowing exploit. The `--tracked-files`
override marks symlinks as `<path> -> <target>`.

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

* docs(guidance): repoint dead skill refs, record alias carve-outs, honest size budgets

Content half of the guidance-unification audit fixes:

- architecture-video SKILL.md told readers to read `src/tools/README.md`
  and `src/workspace/README.md` — the v1 monolith is gone
  (`git ls-files | grep -c '^src/'` is 0) and the gate structurally
  cannot flag first-segment-dead paths. Repointed at the Reborn
  successors: `crates/extensions/AGENTS.md` and
  `crates/domains/ironclaw_memory/README.md`.
- guidance-conventions.md now records what only commit messages knew:
  the composition real-file exception beside the root one; the four
  sanctioned ironclaw_agent_loop sub-module CLAUDE.md guides; and the
  alias rule's actual scope (root + crates/**), naming the two
  out-of-scope AGENTS.md (docs/reborn/contracts, ironclaw_silk_decoder)
  instead of a "wherever" wording the tree contradicted.
- Size budgets re-derived from measurement (family <=220, crate <=160)
  with the four crate-tier exceptions named and reasoned. The shipped
  <=150/<=80 numbers were exceeded by 3 family and 24 of 54 crate docs
  on day one, which made the budget unreadable as a signal. No document
  was padded or truncated to fit.
- Root CLAUDE.md used the dated-correction glyph on the deliberately
  untracked `.codebase-memory/artifact.json` reference — suppression
  duty outside the glyph's documented historical-prose meaning. Swapped
  for `<!-- check-guidance: path-ok -->` beside the reference.

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

* review(7306): CodeRabbit triage — guidance gate runs for the files it governs, brace globs, honest floors, identity-column family tables, doc-truth fixes

Trigger (Major, the inert-guard finding): fast-checks was gated on has_code,
whose regex covers none of .claude/, the root AGENTS.md/CLAUDE.md pair, or
docs/ — so a PR editing only a rule's paths: trigger skipped the gate built
for exactly that change. New has_guidance output OR-s those surfaces into
fast-checks only (clippy/JS lanes stay code-scoped); has_code keeps its
pinned meaning. Pinned by a ws12_workflow_contracts.py row and verified by
replaying representative change lists through the workflow's own extracted
EREs.

check-guidance.py: glob_to_regex now translates {a,b} brace alternation
(nested; unmatched braces stay literal) so a legitimate crates/**/*.{rs,toml}
trigger counts as live instead of being reported dead; MIN_RULE_GLOBS 1->20
and MIN_ALIAS_PAIRS 10->40 (~half of measured 38/65, so a degraded parser
refuses instead of passing); family-table coverage now requires the crate in
a row's identity (first) column — an incidental mention in another row's
prose no longer counts (measured 0 regressions on the live tree). Self-tests:
+3 (brace trigger end-to-end, duplicate KNOWN_MISSING rows, identity-column
regression) and the real-repository case documents its deliberate git
coupling. Floors sabotage-verified.

Doc truth, measured against code: composition CONTRACT — WS stream shares
SseCapacity (stream_events_ws try_acquire, pinned test) replacing 'No WS
surface to bound', webui_v2_app returns Result<Router, WebuiServeError>;
llm CONTRACT — the circuit breaker wraps failover (apply_decorator_chain
order), not the reverse; filesystem CONTRACT — dependency rule now names the
real manifest set (+libsql_runtime, +observability); extension_manager
AGENTS — the loops layer flip landed (layer = "loops"); four stale 'has no
CLAUDE.md' claims updated for the new symlink aliases (config, common,
event_store x2); root AGENTS — clippy line gains -- -D warnings (CI denies
warnings; unflagged clippy exits 0 with them) and the error bullet routes to
.claude/rules/error-handling.md; assistant/webui validation sections document
the real lane structure (self-dev-dep unifies test-support on, so the missing
shape is the no-dev-deps production lane, the #7119 class).

Stale pre-family paths in .rs prose: 594 crates/ironclaw_* citations
measured; 130 sit in comments, of which 106 repointed to their family homes
(every rewritten path verified to resolve), 10 of those needed deeper
repoints (files that moved crates: capability_host.rs, channel_pairing.rs,
approval_store_contract.rs, secret_store.rs, loop_contracts
instruction_bundle.rs, assistant communication_context.rs, loop_host
surface_disclosure.rs, resolver_tests.rs), 24 left deliberately (flat-
spelling narration about the family move itself, deleted-crate history,
synthetic fixture names, and two #6945-class pointers whose target is gone
at every spelling). 464 string-literal citations left: the specificity
test resolves legacy spellings through the crate inventory by design.

Triage of PR #7306 review comments; no gate weakened, both alias
exceptions preserved.

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

* review(7306): drop the one comment repoint in tests/e2e_trace_runtime_policy_org_ceiling_yolo.rs

reborn_pr_test_plan.py has no mapping for this root test (it matches
neither the tests/reborn_* partition inventory nor any other arm), so ANY
PR touching it fails 'Detect Reborn test scope' — a pre-existing planner
gap, confirmed against origin/main with a one-file changed list. The stale
crates/ironclaw_runtime_policy comment path inside it stays until the
planner learns the file; noted for follow-up rather than smuggling planner
surgery into a review-triage branch.

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

* docs(contributing): stop annotating the loose iteration clippy line as 'zero warnings'

Same class as the root AGENTS.md fix: unflagged clippy exits 0 with
warnings, so the annotation overclaimed. CONTRIBUTING's two-tier design
(loose iteration block, then a stricter pre-PR block that already carries
-- -D warnings) is deliberate and stays; only the claim is aligned.

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

* docs: recount the frozen WebUI route table after the #7306 merge — 93 -> 97

#7236 (main) added the four operator inspector routes without bumping the
stated counts; re-derived on the merged tree:
rg -c 'pub const WEBUI_V2_ROUTE_' crates/product/ironclaw_webui/src/webui_v2/descriptors.rs -> 97.
Updates the two live claims (webui README, PROPOSAL SS6.9.4 with its
strike-through recount convention); historical/superseded 92-row mentions
stay as written.

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

* fix(ci): the code-style roll-up must judge fast-checks for guidance-only PRs

The has_guidance trigger made fast-checks RUN for .claude/ and root-pair
changes, but the roll-up's has_code==false branch exits 0 before it ever reads
fast-checks' result — so check-guidance.py could fail and Code Style would
still report success. The gate ran and could never block: exactly the
inert-guard shape this change exists to remove, reintroduced one layer up.

Fixed the way main's docs-publication gate already does it — judged before the
early exit, with the reason in a comment. Sabotage-verified: has_guidance=true
plus fast-checks=failure now exits 1.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 12:18:15 +00:00
Benjamin Kurrek
a849373c41 fix(slack): restore personal delivery and standardized canaries (#7300)
* fix(qa): align Slack canaries with messaging standard

* fix(slack): restore recency-ordered message search

* fix(slack): retain provisioned personal DM targets

* test(slack): cover default sort and DM scope resolution
2026-08-07 12:15:00 +00:00
firat.sertgoz
12e5a30dad feat(loop-host): add schema-aware deferred tool search (#7273)
* feat(loop-host): add schema-aware deferred tool search

* test(loop-host): strengthen tool search isolation coverage

* test(loop-host): expand tool search relevance benchmark

* docs(loop-host): clarify retrieval recall semantics

* fix(loop-host): address serrrfirat review — harden tool search bounds (#7273)

* fix(loop-host): address coderabbit review — gate test fixture (#7273)

* fix(loop-host): address coderabbit review — honor full surface policy (#7273)
2026-08-07 12:11:19 +00:00
jinxin
cbb3515477 feat(inspector): add prompt inspection and Prompt tab (#7239)
* feat(inspector): add operator inspection API

* docs(inspector): assign product service ownership

* test(inspector): ratchet diagnostic contracts

* feat(inspector): add debug panel shell

* test(inspector): cover debug panel shell e2e

* fix(inspector): stop diagnostics when panel closes

* feat(inspector): add prompt inspection

* fix(inspector): follow current webui ownership

* test(inspector): cover prompt diagnostics e2e

* fix(inspector): redact prompt metadata

* fix(inspector): classify prompt instruction sources

* fix(inspector): address review feedback

* fix(inspector): retry transient snapshot failures

* fix(inspector): address prompt diagnostic review findings

* fix(inspector): follow debug query navigation

* fix(inspector): preserve stream terminal state

* fix(inspector): capture full capability surface

* fix(inspector): address prompt diagnostic review feedback
2026-08-07 12:05:21 +00:00
Henry Park
389aa99971 fix(auth): omit the OAuth scope parameter when the ceiling is empty (#7309)
* fix(auth): omit the OAuth scope parameter when the ceiling is empty

`build_recipe_authorization_url` appended the scope parameter
unconditionally, so a recipe with an empty scope ceiling emitted
`scope=` — present but valueless. RFC 6749 §3.3 makes `scope` optional
but requires at least one token when it appears, and authorization
servers enforce that: Attio answers `scope=` with `400 invalid_scope`
while accepting the identical request with the parameter absent. That
blocked every hosted-MCP OAuth registration, which has no
manifest-declared scopes and so always builds an empty ceiling.

Guard the append on a non-empty scope list.

Verified against both live servers whose recipes carry an empty
ceiling. Attio rejects only the empty-valued form. Notion parses an
omitted and an empty parameter identically (`"scope":[]` in the state
it echoes back), so the bundled notion-mcp extension changes URL bytes
and nothing else. Every other bundled manifest declares a non-empty
ceiling and is byte-identical.

Regression test extends
`extension_oauth_start_enforces_the_recipe_scope_ceiling`, which
already drives the real `POST /setup/oauth/start` route through
`AuthEngine::prepare_oauth_flow` into this builder, with an
empty-ceiling case asserting the key is absent rather than empty.

Refs #7308

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

* test(auth): pin the empty-ceiling authorize URL at the gate caller

`build_recipe_authorization_url` has two production callers: the
Settings route `POST /setup/oauth/start` and the turn-blocked capability
gate. Only the former pinned the empty-ceiling case, and the gate is the
caller that reaches it FIRST for a hosted-MCP vendor — a blocked turn
builds the authorize URL before any Settings-initiated connect runs.

Verified discriminating: with the guard in `build_recipe_authorization_url`
defeated, this test fails with `&scope=&` in the emitted URL.

Refs #7308

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

* fix(auth): keep OAuth rationale provider-neutral

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: serrrfirat <f@nuff.tech>
2026-08-07 09:58:29 +00:00
jinxin
28845435c8 feat(inspector): add debug panel shell and live diagnostics client (#7236)
* feat(inspector): add operator inspection API

* docs(inspector): assign product service ownership

* test(inspector): ratchet diagnostic contracts

* feat(inspector): add debug panel shell

* test(inspector): cover debug panel shell e2e

* fix(inspector): stop diagnostics when panel closes

* fix(inspector): follow current webui ownership

* fix(inspector): address review feedback

* fix(inspector): retry transient snapshot failures

* fix(inspector): follow debug query navigation

* fix(inspector): preserve stream terminal state
2026-08-07 09:43:34 +00:00
Pranav Raja
aff7d90cb6 fix(reborn): make installed and agent-authored skills selectable, installable, and complete (#6745)
* 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>

* 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): 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>

* 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>

* 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>

* 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>

* 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): move the design doc under the fenced internal tree

`docs: enforce the docs/ publication boundary` landed on main at 00:53 and dequeued this PR: every
file under `docs/` must be either published (referenced from `docs.json` navigation) or fenced (by
`docs/.mintignore`), and `docs/skills/agent_authored_bundles.md` was neither — so it would have been
deployed to the public docs site and indexed. That is exactly what the gate exists to catch, and the
doc predates the rule rather than breaking it.

`.mintignore` is frozen by that same change ("all new internal material goes under internal/"), so
the fix is the move, not a new fence entry. `docs/internal/` is already fenced.

`scripts/ci/test_docs_publication_boundary.py`: 21/21 pass. Workspace `--all-features` clippy on the
production target set clean, fmt clean, panic gate clean.

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

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:43:29 +00:00
firat.sertgoz
43d0494c46 fix(filesystem): make libSQL FTS safe for natural-language recall (#7288)
* fix(filesystem): treat FTS filters as plain text

* fix(filesystem): address review — PG stop list, term-match tests, DRY (#7288)

Addresses multi-agent review on #7288:

- Plain-FTS stop words now mirror PostgreSQL's fixed english stop list
  (shared/english.stop) verbatim, so in-memory/libSQL required terms match
  plainto_tsquery('english', ...) exactly; documents the remaining
  stemming divergence (FTS5 matches literal terms).
- Exhaustive table-driven test pins every stop word (case-insensitive) plus
  required non-stop words (please/tell/would/could).
- libsql FTS contract test adds a partial-match negative document so the
  FTS5 implicit-AND join is distinguishable from an OR join, and proves the
  negative doc is searchable by its own terms.
- In-memory reference matcher now tokenizes stored text (whole-token
  matching, mirroring FTS5 unicode61) instead of substring containment,
  fixing contractions divergence; FTS queries are tokenized once per query
  instead of once per scanned record.
- core_builtin harness: shared-filesystem variant reuses the recording
  harness assembly tail instead of re-copying it.

* test(integration): prove memory recall is scope-isolated on the libSQL path (#7288)

The proactive-recall scenario only checked that a never-written marker was
absent, which says nothing about scope isolation. Seed a second user's
MEMORY.md — word-for-word the canonical document apart from the marker —
into the same libSQL composite, then assert the canonical user's explicit
memory_search and proactive prompt both still return plum-42 and never the
other user's marker, while that marker stays retrievable in its own scope.

The seed goes through the native provider rather than a second actor's
thread: this group pins capability dispatch to one fixed user, so a second
actor's memory write would land in the canonical scope anyway.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 08:20:28 +00:00
firat.sertgoz
4a11fc832a fix(capabilities): unify disclosure and enforcement policy (#7233)
* fix(capabilities): unify disclosure and enforcement policy

* fix: share resolved capability surface with host

* fix: address capability surface review feedback

* fix: map golden snapshots to Reborn test lane

* refactor: unify visible surface filtering

* test: enforce filtered batch outcome contract

* fix: validate filtered batch suspension state

* test: pin golden snapshot lane ownership

* test: avoid artifact mapping order dependency

* fix(review): address remaining PR feedback
2026-08-07 08:08:59 +00:00
dependabot[bot]
e54a9bb6b9 chore(deps): bump brace-expansion in /docs/internal/architecture-video (#7311)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 1.1.13 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.13...v1.1.18)

Updates `brace-expansion` from 2.0.3 to 2.1.4
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.13...v1.1.18)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 07:47:11 +00:00
dependabot[bot]
03f1fee648 chore(deps): bump fast-uri in /docs/internal/architecture-video (#7312)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-07 07:46:53 +00:00