Skip to content

feat(skills): activation feedback pipeline + install idempotence - #2530

Merged
ilblackdragon merged 6 commits into
stagingfrom
feat/skill-activation-feedback
Apr 19, 2026
Merged

ilblackdragon merged 6 commits into
stagingfrom
feat/skill-activation-feedback

Conversation

@serrrfirat

@serrrfirat serrrfirat commented Apr 16, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

  • SkillActivated event now carries feedback notes explaining why skills were selected/rejected
  • v1 dispatcher emits StatusUpdate::SkillActivated with feedback produced by the selector (chain-load decisions, budget exhaustion, setup-marker skips, explicit /mention force-activations)
  • Channels propagate activation feedback to gateway for UI rendering
  • skill_install tool no longer prompts for approval when a skill is already loaded (idempotence fix)

Cherry-picked from #2504, with the v1 producer wired up in this PR.

Test plan

  • cargo test passes
  • Verify skill activation events include feedback notes in SSE stream
  • Confirm skill_install with already-loaded skill returns success without approval prompt

🤖 Generated with Claude Code

Add an optional `feedback: Vec<String>` field to the SkillActivated
event so the engine and selector can surface human-readable activation
notes (chain-load reasons, marker exclusions, scoring summaries) to the
UI. Wire the field through the StatusUpdate, the SSE bridge, and the
gateway's activity timeline; serialize-skip empty vectors so the wire
format stays backwards compatible.
When the LLM force-activates a persona via `/ceo-setup` it sometimes
follows up with a redundant `skill_install("ceo-setup")` call. The
`execute` path was already idempotent (returns `already_installed`
without touching the catalog), but `requires_approval` still gated
the call behind a confirmation prompt — pure friction on a guaranteed
no-op.

Mirror the idempotent shortcut in `requires_approval`: when a skill
with the requested name is already loaded (bundled, user, workspace,
or previously installed), return `ApprovalRequirement::Never`. The
shortcut wins even when `install_dependencies=true` because the
top-level execute is still a no-op (companions get reconciled by their
own activation paths). Regression test covers all three cases.
@github-actions github-actions Bot added scope: channel Channel infrastructure scope: channel/web Web gateway channel scope: tool/builtin Built-in tools size: M 50-199 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 16, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a feedback field to skill activation events to display human-readable notes in the UI and updates the SkillInstallTool to skip approval prompts for already-loaded skills. Review feedback identifies a security vulnerability where the idempotency check incorrectly bypasses approval even when install_dependencies is true, potentially allowing unvetted code. Additionally, a contradiction was noted in the regression test where the assertion for dependency installation did not match the intended security behavior.

Comment thread src/tools/builtin/skill_tools.rs
Comment thread src/tools/builtin/skill_tools.rs

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: idempotence shortcut weakens approval on dependency installs

The activation-feedback pieces look fine, but the new skill_install approval shortcut introduces a verified security regression.

Critical: install_dependencies=true now bypasses approval when the top-level skill is already loaded

File: src/tools/builtin/skill_tools.rs:864
requires_approval() returns ApprovalRequirement::Never as soon as registry.has(name) is true, before it checks install_dependencies. That means a request like {"name":"ceo-setup","install_dependencies":true} skips approval entirely even though execution can still fetch and install additional companion skills. The new regression test at the bottom of the file locks in that unsafe behavior rather than catching it.
Suggested fix: only take the no-op shortcut when install_dependencies is false; dependency installs still need the existing approval gate.

Summary:

  • Recommended verdict: Request changes
  • Prior feedback status: partially unresolved; the prior blocker about install_dependencies=true bypassing approval still applies
  • Residual risk: this path changes a trust boundary, so I would want the regression test updated alongside the fix.

@zmanian zmanian left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Adds a feedback: Vec<String> field to SkillActivated events (wire-compatible via skip_serializing_if), renders skill activation cards in the web UI, and short-circuits skill_install's approval prompt when the named skill is already loaded. Clean, small change — but the approval-skip path for install_dependencies=true deserves a closer look.

Findings

Blocking

None strictly, but see the first suggestion.

Suggestions

  • Approval bypass with install_dependencies=true (src/tools/builtin/skill_tools.rs:854-875 + test at :1407). The shortcut returns ApprovalRequirement::Never whenever the top-level skill name is already loaded, INCLUDING when install_dependencies=true. The test asserts this as correct with the comment "known-name shortcut wins over install_dependencies (execute is a no-op)". This relies on execute() returning already_installed and refusing to touch companions even when install_dependencies=true is set. If that contract ever changes — or if a future refactor makes install_dependencies=true walk the companion list when the top skill is loaded — we have a silent approval bypass that pulls in additional prompt-injection surface. Two ways to harden:
    1. Pin the contract with a caller-level test that drives SkillInstallTool::execute with {"name": "loaded-skill", "install_dependencies": true} and asserts no companion was installed. Right now only requires_approval is tested in this scenario.
    2. Or: don't take the shortcut when install_dependencies=true — fall through to the existing gated path. The prompt cost is minimal and the safety property is easier to reason about.
  • has(name) vs name+source check. If an attacker's skill name: code-review is already loaded (say, an unrelated user-placed skill with the same name), an LLM can now skill_install {name: code-review} from a different source without approval. The dedup is by name only. If SkillRegistry::has is purely name-based, this is an identity-spoofing lane. Worth confirming — if has() checks name+source/version, disregard.
  • Feedback strings are unbounded. No length or count cap. SSE payloads are small today, but a skill that dumps 50 lines of "gated out because X" notes becomes ugly on narrow viewports. Consider truncating each note to ~200 chars and capping the list to ~10 entries on the server side.
  • Comment drift on src/bridge/router.rs:3328-3333. "The engine event doesn't carry feedback yet; callers in the v1 path populate it directly on StatusUpdate" — this is a coordination seam (v1 populates StatusUpdate.feedback, v2 doesn't yet). When v2 gains feedback support, this TODO will be silent unless somebody searches for Vec::new() in this file. Consider a // TODO(engine-v2-feedback): tag so a future grep finds it.

Nits

  • crates/ironclaw_gateway/static/app.js:909-914 — the JSON.parse(e.data) is unguarded. Existing handlers follow the same pattern, so no worse than before; flagging as a wider-pattern hygiene note.
  • addSkillActivationCard appends directly to getOrCreateActivityGroup() without a max-cards guard. A flurry of activations will grow the DOM without bound. Low priority.

Verdict

COMMENT — ship after confirming the install_dependencies=true + already-loaded path doesn't install companions, or gate the shortcut on install_dependencies != true for defense in depth. The rest are minor.

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: loaded-skill shortcut still defeats install_dependencies=true

The activation-feedback pieces look fine, but the idempotence shortcut still breaks the dependency-install path in the current head.

Concerning: install_dependencies=true is still ignored when the top-level skill is already loaded

File: src/tools/builtin/skill_tools.rs:664 and src/tools/builtin/skill_tools.rs:856
Both execute() and requires_approval() still short-circuit on registry.has(name) before honoring install_dependencies. That means a request like {"name":"ceo-setup","install_dependencies":true} returns already_installed immediately and never walks companions, even though the caller explicitly asked for dependency installation. The regression test at src/tools/builtin/skill_tools.rs:1405 still encodes the opposite expectation in its comment, which makes the contract even harder to reason about.

Suggested fix: only take the idempotent shortcut when install_dependencies is false, and update the regression test to pin that behavior.

Summary:

  • Recommended verdict: Request changes
  • Prior feedback status: partially unresolved; the loaded-skill shortcut still wins over the dependency-install path
  • Residual risk: this also keeps the approval contract coupled to a no-op assumption, so the behavior is brittle around future refactors.

@github-actions github-actions Bot added size: L 200-499 changed lines and removed size: M 50-199 changed lines labels Apr 17, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the dependency-install approval feedback in f3adc5e.

What changed:

  • requires_approval() now gates the already-loaded shortcut on install_dependencies == false; dependency-chain requests return ApprovalRequirement::Always.
  • SkillInstallTool::execute() no longer returns already_installed before honoring install_dependencies=true. If the top-level skill is already loaded and declares companions, it walks the loaded skill's requires.skills list through the chain installer.
  • Updated the approval regression test and added a caller-level SkillInstallTool::execute() regression test for the already-loaded + install_dependencies=true path.

Verified with:

  • cargo test -p ironclaw --lib skill_install
  • cargo fmt --check
  • git diff --check

@henrypark133 this should address the change-requested comments about both approval and execution short-circuiting. @zmanian I took the defense-in-depth route you suggested: the shortcut no longer applies when install_dependencies=true.

@henrypark133 henrypark133 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: activation notes never reach the emitted events

The dependency-install approval regression looks fixed, but the new activation-feedback feature still does not make it to any emitted skill-activation event.

Concerning: feedback is added to the event schema, but every emission path still sends an empty vector

File: src/agent/agent_loop.rs:554, src/bridge/router.rs:3329, src/bridge/router.rs:3434
The new UI card expects AppEvent::SkillActivated.feedback, but select_active_skills() still returns only (skills, rewritten_message) and never computes or returns any notes. Both bridge mappings then hard-code feedback: Vec::new(), so the gateway can only ever render the skill names. In practice that means the advertised notes such as "chain-loaded from ..." or "excluded by setup marker" are never surfaced on either the channel-status path or the SSE event path.
Suggested fix: Thread the activation notes out of skill selection (or the engine event) and populate StatusUpdate::SkillActivated / AppEvent::SkillActivated from that source, then add a caller-level test that asserts non-empty feedback reaches the web event.

@ilblackdragon

Copy link
Copy Markdown
Member

Code Review

Overview

Adds feedback: Vec<String> to SkillActivated (engine → status → SSE), a web UI card that renders it, and fixes an approval-prompt loop where skill_install on an already-loaded skill would gate the LLM even though execute returns already_installed immediately.

Strengths

  • Wire-format compat: #[serde(default, skip_serializing_if = "Vec::is_empty")] on the new field keeps old clients and log replays working.
  • Idempotence fix is tightly scoped: the requires_approval shortcut in src/tools/builtin/skill_tools.rs:905-923 mirrors the execute no-op path exactly, and preserves the install_dependencies=true override.
  • Lock discipline: the loaded_required_skills block holds the registry read guard only long enough to clone requires.skills, then drops it before the async dependency fetch (skill_tools.rs:716-736).
  • UI is XSS-safe: addSkillActivationCard (app.js:2098-2134) uses createElement + textContent, no innerHTML.
  • Test coverage on the new approval branch is good — happy path, unknown name, and install_dependencies=true override are all covered, plus the non-obvious "top skill loaded, companion missing" case.

Concerns

1. PR body overclaims — curly-quote / em-dash normalization isn't in this diff.
The body lists "Skill selector normalizes typographic punctuation (curly quotes, em-dashes) during matching" as a change. No such code is present; grep finds quote normalization only in file_edit_guard.rs and tests/html_to_markdown.rs, neither touched here. Drop the bullet or rebase in the missing commit.

2. Feedback pipeline has no producer — scaffolding-only.
Every SkillActivated emit site passes feedback: Vec::new():

  • src/bridge/router.rs:3332 (v1 engine → channel)
  • src/bridge/router.rs:3437 (thread_event_to_app_events)
  • src/channels/web/mod.rs (forwards whatever it receives; all upstream producers send empty)

The comment at router.rs:3331 points at agent_loop::select_active_skills as the populator, but that function returns (Vec<LoadedSkill>, String) and produces no feedback anywhere. The UI card's feedback loop in app.js:2125-2129 will never render a note until a follow-up lands. Suggestions, in order of preference:

  • Include one real producer (chain-load notes and setup-marker exclusions are already tracked in-repo and would be natural first cases), or
  • Reword the description + the router.rs comment so neither promises a populator that doesn't exist.

3. addSkillActivationCard unconditionally scrolls to bottom.
app.js:2133-2134 sets container.scrollTop = container.scrollHeight regardless of the user's scroll position — if someone is reading history when a turn starts, this yanks them down. Matches the surrounding convention, but worth confirming the codebase has a "stick-to-bottom only when already at bottom" helper before this pattern spreads further.

4. Test-plan checkboxes still unchecked. At minimum confirm cargo test before merge; the new unit tests don't exercise SSE serialization of the optional field or the JS card rendering.

Style / Conventions

Matches CLAUDE.md: no info!/warn! on background paths, no inlined prompt templates, no .unwrap() outside tests. Extracting append_chain_install_report_fields + build_already_installed_output cleanly dedupes the three success branches.

Risk Summary

Area Risk Notes
Wire compat Low skip_serializing_if + default
Approval shortcut Low Guarded on !install_deps + has(name); tested
Dead-feedback field Medium-UX Users won't see promised notes until a follow-up
PR body ↔ code Medium-process Punctuation-normalization bullet has no matching code

Recommendation

Request changes, small ones. Fix the router.rs comment (or wire up one real producer) and either drop or add back the punctuation-normalization change referenced in the description. The idempotence fix alone is ready as-is.

@ilblackdragon ilblackdragon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overview

Cherry-pick of two independent changes from #2504:

  1. Plumb feedback: Vec<String> through StatusUpdate::SkillActivated → AppEvent::SkillActivated → SSE → web UI activity card.
  2. Make skill_install idempotent when a skill is already loaded: no approval prompt, no 404 against ClawHub. install_dependencies=true still walks companions.

🔴 Blocking

Build is broken — all 3 Clippy CI jobs fail. crates/ironclaw_common/src/event.rs:523 constructs AppEvent::SkillActivated { skill_names, thread_id } in the variant-enumeration list without the new feedback field:

error[E0063]: missing field `feedback` in initializer of `event::AppEvent`
  --> crates/ironclaw_common/src/event.rs:523

The enum definition was updated in the same file but this sibling constructor was missed.

Pattern matches in non-updated files also break (likely masked by compiler stopping at first error):

  • tests/e2e_github_dev_workflow.rs:165 — StatusUpdate::SkillActivated { skill_names }
  • tests/support/test_rig.rs:409 — StatusUpdate::SkillActivated { skill_names }
  • tests/support/live_harness.rs:400 — existing arm still { skill_names } (the diff adds a new arm at line ~158 but leaves the old one untouched)

All need , .. added.

⚠️ Correctness / Design

Feedback pipeline has no producer. The field is threaded end-to-end, but every construction site passes Vec::new():

  • src/bridge/router.rs:3716 — v1 path (comment claims agent_loop::select_active_skills populates it, but that change isn't in this PR)
  • src/bridge/router.rs:3438 — v2 engine path

So the UI card renders, but never with notes. Either land the producer in the same PR, or be explicit in the body that this is plumbing-only.

PR description claim not in the diff. Body says "Skill selector normalizes typographic punctuation (curly quotes, em-dashes) during matching" — no changes to crates/ironclaw_skills/src/selector.rs are present. Either the feature was dropped during cherry-pick or the description is stale.

✅ What Works Well

  • append_chain_install_report_fields extraction cleanly deduplicates report-to-JSON rendering.
  • Two-phase lock pattern (loaded_required_skills collected under read guard, .await after drop) correctly avoids holding RwLockReadGuard across await.
  • requires_approval shortcut mirrors the execute no-op path; the install_dependencies=true carve-out is correctly gated.
  • #[serde(default, skip_serializing_if = "Vec::is_empty")] on feedback preserves SSE wire-format back-compat.
  • Regression tests for both approval-skip and dependency-install-when-already-loaded are tight and well-commented.

Minor

  • feedback: Vec<String> is unstructured — long-term, an enum of reasons (ChainLoaded, ExcludedBySetupMarker, BudgetExhausted) would align with .claude/rules/types.md. Not blocking.
  • No test covers the SSE → UI feedback rendering; acceptable given there's no producer yet, but add one when producers land.

Recommendation

Fix the event.rs:523 constructor + the three unupdated pattern sites, then either (a) wire up a real feedback producer or (b) re-scope the PR title/body to "plumbing + idempotence". Also remove the stale punctuation-normalization bullet.

…s list

The variant-enumeration constructor in event.rs:501 was missed when
the new `feedback` field was added to AppEvent::SkillActivated, breaking
the build with E0063. All three Clippy CI jobs failed on this.

Regression: covered by `cargo build --all-features`, which fails to
compile if any variant in this list is constructed with missing fields.
@ilblackdragon

Copy link
Copy Markdown
Member

Pushed 30d575c to fix the immediate build break (E0063 on AppEvent::SkillActivated constructor in the variant-enumeration list).

⚠️ This branch is ~50 commits behind staging. After rebase, three additional sites in staging will need , .. added to pattern matches now that the variant has a new field:

  • tests/e2e_github_dev_workflow.rs:165
  • tests/support/test_rig.rs:409
  • tests/support/live_harness.rs:400 (the existing arm; the new arm added by this PR is already correct)

There are also pre-existing clippy warnings (crates/ironclaw_tui/src/render.rs, crates/ironclaw_skills/src/catalog.rs) that fail under -D warnings on this branch but appear fixed on staging — rebase should clear them.

The blocking-feedback items from the review (no producer for feedback; missing punctuation-normalization claim from PR body) are unchanged and worth addressing before merge.

Resolves conflicts and updates the three call sites that now need `, ..`
on `StatusUpdate::SkillActivated` patterns because of the new `feedback`
field:

- `tests/e2e_github_dev_workflow.rs:165`
- `tests/support/test_rig.rs:409`
- `tests/support/live_harness.rs:400`

Also reworks the web UI skill-activation card to the new activity
controller model that landed on staging (`_chatToolActivity` with
`getOrCreateGroup` / `removeThinking`) — the old `_activityThinking`
module-level var is gone, so `addSkillActivationCard` now delegates
through the public helpers.

Test fixes:
- `skill_install_skips_approval_when_already_loaded`
- `skill_install_execute_honors_dependencies_when_already_loaded`
  split `install_skill` into `prepare_install_to_disk` + `commit_install`
  to avoid holding the `std::sync::RwLock` write guard across `.await`
  (clippy `await_holding_lock`).

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

Copy link
Copy Markdown
Member

Merged staging (dca5fc6). Resolved the two conflicts and fixed the post-merge fallout:

Conflict resolutions:

  • tests/support/live_harness.rs — took the { skill_names, .. } side
  • crates/ironclaw_gateway/static/app.js — staging rewrote the activity cards into the _chatToolActivity controller, so addSkillActivationCard now delegates through getOrCreateActivityGroup() + removeActivityThinking() instead of touching the now-removed _activityThinking var. The duplicate removeActivityThinking shim from the PR was dropped (staging has its own) and the PR's addToolCard(name) changes were dropped because staging replaced it with createToolActivityCard(entry, options).

Pattern updates: added , .. to the three staging-side StatusUpdate::SkillActivated { skill_names } matches (tests/e2e_github_dev_workflow.rs:165, tests/support/test_rig.rs:409, tests/support/live_harness.rs:400).

New clippy fix: the two #[tokio::test] functions in this PR held a std::sync::RwLock write guard across .await (clippy await_holding_lock, error under -D warnings). Reworked them to the production pattern: SkillRegistry::prepare_install_to_disk (async, no lock) + commit_install (sync, with lock). Tests still pass.

Full cargo clippy --all --benches --tests --examples --all-features -- -D warnings is clean, cargo fmt --check is clean, and all 5 skill_install unit tests pass.

The two review items that aren't mechanical — no producer for feedback on either engine path, and the stale punctuation-normalization claim in the PR body — are still unaddressed and worth resolving before merge.

The `SkillActivated` event carried an empty `feedback` field because
nothing populated it. This adds the producer end of the pipeline.

**Selector:**
- `prefilter_skills` now returns `SelectionOutcome { selected, notes }`.
- `try_select` returns a reason enum (`Selected`, `BudgetFull`,
  `CandidateLimit`, `MarkerSatisfied`, `AlreadySelected`) so callers
  can render distinct notes instead of opaque "skipped".
- Notes generated for:
  - `<companion>: chain-loaded from <parent>`
  - `<companion>: chain-load skipped (budget full)`
  - `<companion>: chain-load skipped (max active skills reached)`
  - `<companion>: chain-load skipped (setup already complete)`
  - `<skill>: skipped (skill context budget exhausted)` for parents
    that scored but didn't fit.

**Agent loop:**
- `select_active_skills` returns the notes alongside selected skills
  and prepends a `<skill>: force-activated via /mention` note for each
  explicit mention.

**Dispatcher:**
- Emits `StatusUpdate::SkillActivated { skill_names, feedback }` via
  `channels.send_status` whenever something activated or notes exist
  (so "nothing loaded because budget exhausted" surfaces too).
- Silent when nothing activated and no notes — no UI noise.

**Stale comment:**
- Router's v2-bridge comment no longer claims v1 callers populate
  feedback "directly on `StatusUpdate`"; the v1 dispatcher now emits
  its own event, and v2 remains empty until the Python orchestrator
  is updated.

Regression: existing selector test `test_chain_load_respects_budget`,
`test_chain_load_skips_companion_with_satisfied_marker`, and
`test_chain_load_is_non_transitive` now also assert that the
corresponding note is in `outcome.notes`. The 42 selector tests and
503 agent-module tests all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Apr 19, 2026
This was referenced Apr 22, 2026
theredspoon pushed a commit to theredspoon/ironclaw that referenced this pull request Jun 21, 2026
…rai#2530)

* feat(events): SkillActivated carries activation feedback notes

Add an optional `feedback: Vec<String>` field to the SkillActivated
event so the engine and selector can surface human-readable activation
notes (chain-load reasons, marker exclusions, scoring summaries) to the
UI. Wire the field through the StatusUpdate, the SSE bridge, and the
gateway's activity timeline; serialize-skip empty vectors so the wire
format stays backwards compatible.

* fix(skills): skill_install never prompts when skill is already loaded

When the LLM force-activates a persona via `/ceo-setup` it sometimes
follows up with a redundant `skill_install("ceo-setup")` call. The
`execute` path was already idempotent (returns `already_installed`
without touching the catalog), but `requires_approval` still gated
the call behind a confirmation prompt — pure friction on a guaranteed
no-op.

Mirror the idempotent shortcut in `requires_approval`: when a skill
with the requested name is already loaded (bundled, user, workspace,
or previously installed), return `ApprovalRequirement::Never`. The
shortcut wins even when `install_dependencies=true` because the
top-level execute is still a no-op (companions get reconciled by their
own activation paths). Regression test covers all three cases.

* fix(skills): preserve approval for dependency installs

* fix(events): include feedback in AppEvent::SkillActivated all-variants list

The variant-enumeration constructor in event.rs:501 was missed when
the new `feedback` field was added to AppEvent::SkillActivated, breaking
the build with E0063. All three Clippy CI jobs failed on this.

Regression: covered by `cargo build --all-features`, which fails to
compile if any variant in this list is constructed with missing fields.

* feat(skills): wire up v1 feedback producer for SkillActivated

The `SkillActivated` event carried an empty `feedback` field because
nothing populated it. This adds the producer end of the pipeline.

**Selector:**
- `prefilter_skills` now returns `SelectionOutcome { selected, notes }`.
- `try_select` returns a reason enum (`Selected`, `BudgetFull`,
  `CandidateLimit`, `MarkerSatisfied`, `AlreadySelected`) so callers
  can render distinct notes instead of opaque "skipped".
- Notes generated for:
  - `<companion>: chain-loaded from <parent>`
  - `<companion>: chain-load skipped (budget full)`
  - `<companion>: chain-load skipped (max active skills reached)`
  - `<companion>: chain-load skipped (setup already complete)`
  - `<skill>: skipped (skill context budget exhausted)` for parents
    that scored but didn't fit.

**Agent loop:**
- `select_active_skills` returns the notes alongside selected skills
  and prepends a `<skill>: force-activated via /mention` note for each
  explicit mention.

**Dispatcher:**
- Emits `StatusUpdate::SkillActivated { skill_names, feedback }` via
  `channels.send_status` whenever something activated or notes exist
  (so "nothing loaded because budget exhausted" surfaces too).
- Silent when nothing activated and no notes — no UI noise.

**Stale comment:**
- Router's v2-bridge comment no longer claims v1 callers populate
  feedback "directly on `StatusUpdate`"; the v1 dispatcher now emits
  its own event, and v2 remains empty until the Python orchestrator
  is updated.

Regression: existing selector test `test_chain_load_respects_budget`,
`test_chain_load_skips_companion_with_satisfied_marker`, and
`test_chain_load_is_non_transitive` now also assert that the
corresponding note is in `outcome.notes`. The 42 selector tests and
503 agent-module tests all pass.

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

---------

Co-authored-by: Illia Polosukhin <ilblackdragon@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: channel Channel infrastructure scope: tool/builtin Built-in tools size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants