diff --git a/hermes_cli/write_approval_commands.py b/hermes_cli/write_approval_commands.py index 5f399e0cc53f1..ad6696feaae4c 100644 --- a/hermes_cli/write_approval_commands.py +++ b/hermes_cli/write_approval_commands.py @@ -38,7 +38,12 @@ def _fmt_pending_list(subsystem: str) -> str: for r in records: origin = r.get("origin", "foreground") tag = " [auto]" if origin == "background_review" else "" - lines.append(f" {r['id']}{tag} {r.get('summary', '')}") + badge = "" + v = r.get("validation") + if v and v != "ok": + reason = v[len("blocked: "):] if v.startswith("blocked: ") else v + badge = " \u26a0\ufe0f BLOCKED: %s" % reason[:70] + lines.append(f" {r['id']}{tag}{badge} {r.get('summary', '')}") where = "/{s} approve ".format(s=subsystem) lines.append("") lines.append(f"Apply: {where} Reject: /{subsystem} reject ") diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index 3d018a3da9bb6..9d46b91b5280b 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -1459,10 +1459,29 @@ def _apply_skill_write_gate(action, name, **payload_kwargs): old_string=payload_kwargs.get("old_string") or "", new_string=payload_kwargs.get("new_string") or "", ) - record = wa.stage_write(wa.SKILLS, payload, summary=gist, origin=wa.current_origin()) + # Pre-validate creates at staging time so a record that can never be + # applied (frontmatter description over SKILL_PROMPT_DESC_LIMIT, missing + # name, missing frontmatter, etc.) is flagged immediately instead of + # failing later at /skills approve — the exact trap that left 26 blocked + # creates in the backlog. The record is STILL staged (nothing is lost); + # the blocker is recorded, shown in /skills pending, and echoed in the + # response so the agent can fix the description before approving. + validation = "ok" + if action == "create": + try: + err = _validate_frontmatter(payload.get("content") or "", new_skill=True) + if err: + validation = "blocked: " + err.splitlines()[0] + except Exception: + validation = "ok" # fail open: never block staging on validator bugs + record = wa.stage_write( + wa.SKILLS, payload, summary=gist, origin=wa.current_origin(), + validation=validation, + ) return json.dumps( {"success": True, "staged": True, "pending_id": record["id"], - "gist": gist, "message": decision.message}, + "gist": gist, "message": decision.message, + "validation": validation}, ensure_ascii=False, ) diff --git a/tools/write_approval.py b/tools/write_approval.py index b017299d806b8..1ee59f97b9cec 100644 --- a/tools/write_approval.py +++ b/tools/write_approval.py @@ -112,7 +112,7 @@ def _pending_dir(subsystem: str) -> Path: def stage_write(subsystem: str, payload: Dict[str, Any], - *, summary: str, origin: str) -> Dict[str, Any]: + *, summary: str, origin: str, validation: str = "ok") -> Dict[str, Any]: """Persist a pending write and return a short record describing it. Args: @@ -124,6 +124,11 @@ def stage_write(subsystem: str, payload: Dict[str, Any], For skills this is the LLM/heuristic gist; for memory it can be the entry text itself. origin: ``foreground`` or ``background_review`` — recorded for audit. + validation: pre-computed apply-time check result for the staged write. + ``"ok"`` (default) means it will apply; anything else is a short + ``"blocked: "`` flag surfaced in pending lists so a record + that can never be applied is visible before approval. Recorded for + audit; never used to refuse staging (a staged write is never lost). Returns a dict with ``id`` and metadata. Best-effort: on disk failure it logs and still returns a record (the write is simply lost, which is the @@ -136,6 +141,7 @@ def stage_write(subsystem: str, payload: Dict[str, Any], "action": payload.get("action", ""), "summary": (summary or "").strip(), "origin": origin or "foreground", + "validation": validation, "created_at": time.time(), "payload": payload, }