Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/system-specs/modules/memory-skills-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -1015,7 +1015,7 @@ imports deduplicate through the provenance ledger.
Hermes-style autonomous skill creation from completed sessions. **Opt-in, and STAGED for approval** — generation is **off by default** (`skills.auto_create_from_sessions` defaults **false**; enable via `kirocrew config set skills.auto_create_from_sessions true` or dashboard Settings → Skills). When on, candidates land in a pending-approval queue (`skills.approval_required` defaults **true**) and nothing goes live unattended. Pipeline: detect (during consolidation) → generate → metadata dedupe → pending queue → human approval → live → archive-if-unused.

Key v2 elements (all under `skills.*`):
- **Staged approval:** new skills route to `auto/.pending/<slug>/`; approve promotes to `auto/<slug>/` (dashboard: Skills → Pending review). Auto-approve for prose-only is opt-in via `approval_required=false`; **script-bearing candidates always require approval**.
- **Staged approval:** new skills route to `auto/.pending/<slug>/`; approve promotes to `auto/<slug>/` (dashboard: Skills → Pending review). Auto-approve for prose-only is opt-in via `approval_required=false` — this covers both new candidates and prose-only **updates** to live auto-skills (an auto-applied update snapshots the prior version to `.versions/` for rollback; a failed auto-apply falls back to the pending queue); **script-bearing candidates and updates always require approval**.
- **Scripts:** deterministic procedures may ship a validated **Python** helper (`generate_scripts`, default true); statically validated (regex denylist + AST policy: no dynamic exec/import, destructive fs, process exec, network egress, ≤4 KB) and re-validated at the approve choke point.
- **Bounding:** archive-not-delete lifecycle `active→stale(`stale_after_days`,30)→archived(`archive_after_days`,90)`, `max_auto_skills` (100) backstop, pin + cron-referenced exemptions, never-used grace floor; pending TTL `pending_ttl_days` (30).
- **Dedupe:** embedding-free metadata comparison over all generated skills (`judge_model`).
Expand Down
6 changes: 4 additions & 2 deletions src/kiro_crew/config/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -3044,8 +3044,10 @@ class SkillsConfig:
metadata=_meta(
"Skill Approval Required",
"When true, auto-generated skill candidates land in a pending queue for "
"human review instead of going live. Prose-only skills may auto-publish "
"when this is false; skills that bundle scripts ALWAYS require approval "
"human review instead of going live. When false, prose-only NEW skills "
"auto-publish and prose-only UPDATES to live auto-skills auto-apply "
"(the prior version is snapshotted under .versions/ for rollback); "
"skills or updates that bundle scripts ALWAYS require approval "
"regardless of this flag.",
),
)
Expand Down
55 changes: 54 additions & 1 deletion src/kiro_crew/dashboard/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,12 @@
from kiro_crew.security import redact_credentials, redact_exfiltration_urls
from kiro_crew.sel import sel
from kiro_crew.skill_usage import register_skill_read_observer
from kiro_crew.skills import SkillsLoader, set_pending_consumed_hook, set_pending_staged_hook
from kiro_crew.skills import (
SkillsLoader,
set_pending_consumed_hook,
set_pending_staged_hook,
set_update_auto_applied_hook,
)
from kiro_crew.tunnel.setup import setup_tunnel

if TYPE_CHECKING:
Expand Down Expand Up @@ -2196,6 +2201,54 @@ def _resolve() -> None:
logger.debug("pending-skill notification resolve failed", exc_info=True)

set_pending_consumed_hook(_on_pending_skill_consumed)

def _on_update_auto_applied(info: dict) -> None:
# Informational only — the update is already live (approval is
# disabled), so this must read as "went live", never as a review
# request. Kept minimal: identity, new version, and the fact that
# the prior version is restorable.
try:
target = str(info.get("target") or info.get("name") or "skill")
slug = str(info.get("slug") or "")
version = info.get("new_version")
description = str(info.get("description") or "").strip()
title = "Skill auto-updated"
head = f"**{target}** auto-updated to v{version}."
if description:
head = f"**{target}** — {description}\n\nAuto-updated to v{version}."
body = (
head
+ "\n\nApplied without review because skill approval is disabled; "
"the previous version was snapshotted and can be restored from "
"the skill's version history."
)
payload = {
"slug": slug,
"target": target,
"new_version": version,
}
skills_url = "/capabilities?tab=skills"

def _emit() -> None:
try:
state.notify("skills", title, body, meta=payload, url=skills_url)
# The candidate left the pending queue; nudge open
# dashboards to refresh it.
state.broadcast_ws("skills.pending_changed", payload)
except Exception:
logger.debug("auto-applied-update notification failed", exc_info=True)

if _gw_loop is not None and not _gw_loop.is_closed():
try:
_gw_loop.call_soon_threadsafe(_emit)
except RuntimeError: # pragma: no cover - loop closing
pass
else:
_emit()
except Exception:
logger.debug("auto-applied-update notification failed", exc_info=True)

set_update_auto_applied_hook(_on_update_auto_applied)
except Exception:
logger.debug("Could not register pending-skill staged hook", exc_info=True)

Expand Down
185 changes: 184 additions & 1 deletion src/kiro_crew/history.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import math
import os
import re
import secrets
import threading
import time as _time
from collections import OrderedDict
Expand Down Expand Up @@ -5273,6 +5274,8 @@ def _stage_skill_update(
triggers: str,
procedure_md: str,
scripts: "list[dict] | None" = None,
scripts_supplied: bool = False,
allow_auto_apply: bool = True,
) -> None:
"""Stage a pending UPDATE candidate for an existing auto-skill.

Expand All @@ -5281,7 +5284,25 @@ def _stage_skill_update(
90s, fail-open); (c) use the redacted merge as the proposed body, else
fall back to the candidate's own procedure (also on oversize); (d) stage
under ``<target-slug>-update`` with ``kind='update'`` metadata; (e) SEL
audit with outcome ``staged_update``."""
audit with outcome ``staged_update``.

When approval is disabled AND the candidate is prose-only (it supplied
no scripts at all — ``scripts_supplied`` mirrors the NEW-candidate rule
that a candidate whose scripts were ALL rejected by the validator still
never auto-publishes), the staged candidate is immediately promoted via
``auto_apply_pending_update`` (same guards + version snapshot as a human
approval) and audited as ``auto_applied_update``. If that promotion
fails for any reason the candidate stays staged for normal review.

``allow_auto_apply=False`` forces the staging path even when the
conditions above would promote: the caller passes it whenever
auto-refine is enabled, because the refine path writes through
``update_auto_skill`` — unlocked, no version bump, no snapshot — and a
refine of the target can arrive from any concurrent session. A refine
does not advance the version frontmatter, so even a promotion
serialized behind it passes the stale-base check and the two writers
last-write-win. Left staged, the candidate is reviewed against
whatever body the refine left behind instead."""
loader = self._skills_loader
if loader is None:
return
Expand Down Expand Up @@ -5382,6 +5403,20 @@ def _redact(text: object) -> str:
_live_description = _frontmatter_value(live_body, "description")
_staged_triggers = _merge_trigger_lists(_live_triggers, triggers)
_staged_description = description or _live_description
# Prose-only + approval disabled → promote immediately after staging —
# unless the caller vetoed it (a same-result refine of this target
# would overwrite the promotion without a snapshot; see docstring).
auto_apply = (
allow_auto_apply
and not self._approval_required
and not scripts
and not scripts_supplied
)
# Collision-proof ownership token: only the .meta.json THIS call writes
# carries it, so the promotion step below can prove the pending entry is
# the one just staged (a deferred re-stage writes nothing, and an
# unrelated candidate staged the same second shares the timestamp).
stage_token = secrets.token_hex(16) if auto_apply else None
name = loader.stage_skill_candidate(
_update_slug,
description=_staged_description,
Expand All @@ -5392,6 +5427,12 @@ def _redact(text: object) -> str:
kind="update",
target=target_key,
base_version=base_version,
# Suppress the awaiting-review notification when this flow is about
# to promote the candidate itself; if the promotion fails, the
# review request is re-fired below so the candidate never sits
# invisible in the queue.
notify=not auto_apply,
stage_token=stage_token,
)
if name:
logger.info("Staged skill update %s (target %s) from session %s", name, target_key, key)
Expand All @@ -5407,6 +5448,13 @@ def _redact(text: object) -> str:
"merged": used_merge,
},
)
if auto_apply:
self._auto_apply_staged_update(
key=key,
staged_name=name,
target_key=target_key,
stage_token=stage_token,
)
else:
logger.info("Skill update staging rejected for target '%s'", target_key)
sel().log_tool_invocation(
Expand All @@ -5417,6 +5465,128 @@ def _redact(text: object) -> str:
metadata={"slug": _update_slug, "reason": "creation_failed"},
)

def _auto_apply_staged_update(
self,
*,
key: str,
staged_name: str,
target_key: str,
stage_token: str | None,
) -> None:
"""Promote a just-staged prose-only update immediately (approval off).

Delegates to ``SkillsLoader.auto_apply_pending_update`` so every guard
of a human approval applies (staleness, layout, redaction, version
snapshot). Fails SAFE: any refusal or error leaves the candidate staged
and re-fires the awaiting-review notification that staging suppressed.
"""
loader = self._skills_loader
if loader is None or not stage_token:
return
staged_slug = staged_name.split("/", 1)[-1]
# Identity check: when the pending queue already holds a candidate for
# every slug in the collision family, ``stage_skill_candidate`` returns
# the name WITHOUT staging (deferred re-stage). Promoting that slug
# would apply a DIFFERENT, previously-staged candidate — so only
# proceed when the pending entry carries the random ``stage_token``
# written by the staging call this flow just made. The token is only
# ever written when staging actually wrote the candidate, so it cannot
# match a deferred re-stage or an unrelated same-second candidate.
is_ours = False
lookup_failed = False
try:
pend = loader.get_pending_skill(staged_slug)
is_ours = (
pend is not None
and pend.get("kind") == "update"
and pend.get("meta", {}).get("stage_token") == stage_token
)
except Exception:
# Transient lookup failure — the candidate this flow just staged
# (with its notification suppressed) is very likely still pending,
# so it must not be left invisible. Distinguish from a genuine
# ownership mismatch, where the pre-existing candidate already
# fired its own notification at staging time.
is_ours = False
lookup_failed = True
if not is_ours:
# A declined promotion is a promotion decision too — audit it so
# the trail explains why the staged candidate was left pending.
sel().log_tool_invocation(
session_key=key,
tool_name="auto_skill_create",
tool_kind="skills",
outcome="auto_apply_failed",
metadata={
"name": staged_name,
"target": target_key,
"reason": "pending_lookup_failed" if lookup_failed else "ownership_mismatch",
},
)
if lookup_failed:
# Re-fire the awaiting-review notification staging suppressed:
# the candidate stays pending and would otherwise sit invisible
# in the queue. emit_pending_staged no-ops if the slug turns
# out not to be pending after all.
try:
loader.emit_pending_staged(staged_slug)
except Exception:
logger.debug("Pending-staged re-notify failed", exc_info=True)
return
applied: "tuple[str, int] | None" = None
try:
applied = loader.auto_apply_pending_update(
staged_slug, expected_stage_token=stage_token
)
except Exception:
logger.warning(
"Auto-apply failed for staged update %s; leaving it pending review",
staged_name,
exc_info=True,
)
applied = None
if applied:
applied_name, new_version = applied
logger.info(
"Auto-applied skill update %s -> v%d (approval disabled)",
applied_name,
new_version,
)
sel().log_tool_invocation(
session_key=key,
tool_name="auto_skill_create",
tool_kind="skills",
outcome="auto_applied_update",
metadata={
"name": staged_name,
"target": target_key,
"new_version": new_version,
},
)
else:
# Audit the failed promotion decision: the success branch records
# ``auto_applied_update``, so a refusal/exception must leave a SEL
# trace too — otherwise the decision is invisible to `security
# audit` and the promotion appears never to have been attempted.
sel().log_tool_invocation(
session_key=key,
tool_name="auto_skill_create",
tool_kind="skills",
outcome="auto_apply_failed",
metadata={
"name": staged_name,
"target": target_key,
"reason": "left_pending_for_review",
},
)
# The candidate is still pending, so surface the review request
# that staging suppressed — otherwise it sits invisible in the
# queue on an instance whose user never opens it.
try:
loader.emit_pending_staged(staged_slug)
except Exception:
logger.debug("Pending-staged re-notify failed", exc_info=True)

def _process_auto_skills(self, result: dict, key: str) -> None:
"""Extract + write auto-generated skills from the consolidation result.

Expand Down Expand Up @@ -5532,13 +5702,26 @@ def _redact(text: object) -> str:
elif verdict == VERDICT_UPDATE and target:
# Same skill, new requirements worth folding in — stage a
# pending UPDATE candidate rather than dropping the learning.
# Auto-apply is disabled outright whenever auto-refine is
# enabled: the refine path overwrites live through
# ``update_auto_skill`` — unlocked, no version bump, no
# snapshot — and a refine of this target can arrive from ANY
# concurrent session, not just this result (a result-scoped
# veto cannot see those). Because a refine leaves the version
# frontmatter unchanged, even a serialized promotion passes
# the stale-base check and the two writers last-write-win,
# silently discarding one side. With refine on, the update
# therefore always stages for human review, where it is
# applied against whatever body the refine left behind.
self._stage_skill_update(
key=key,
target_key=target,
description=description,
triggers=triggers,
procedure_md=procedure_md,
scripts=valid_scripts or None,
scripts_supplied=scripts_supplied,
allow_auto_apply=not self._auto_refine_enabled,
)
else:
provenance = AutoSkillProvenance(
Expand Down
Loading
Loading