skill-builder
Create a metadata-complete AgentOps skill source package, regenerate its derived projections, and check or repair structural hygiene in skill packages. Triggers: "create a skill", "scaffold skill", "absorb external skill", "new skill", "heal skill", "repair skill hygiene", "audit
Install
npx skills add https://github.com/boshu2/agentops/tree/main/skills/skill-builder
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install boshu2-agentops@llmmart
git clone https://github.com/boshu2/agentops.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole boshu2/agentops collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Skill Builder
Create, repair, audit or export one canonical skill package, or turn supported expertise into a small authoring proposal. Search existing owners before adding a root. Extend the owner that already handles the behavior.
Choose the requested operation
| Need | Entry point |
|---|---|
| Create a source package | scripts/build.sh with from-scratch, from-template or absorb-external |
| Check package structure | scripts/heal.sh --check [--strict] skills/<slug> |
| Repair owned projections | scripts/heal.sh --fix skills/<slug> |
| Audit authoring quality | scripts/audit.sh [--strict] [--json <path>] skills/<slug> |
| Export to another platform | Conversion |
| Make repeated expertise reusable | Distill expertise |
Run only the selected operation. Skills remain optional tools within the native caller's authorized outcome; this skill does not add execution phases, own work, operate Git, validate a software candidate, or decide delivery and retries.
Create and maintain
Treat external skills as structural signals only. Clean-room output must not
copy their names, prose, prompts, scripts or examples. from-template reuses
metadata defaults; absorb-external <slug> --from <path> verifies an input and
creates a blank source package. Neither imports another skill's content.
For creation, supply one input to scripts/build.sh, then replace placeholders
with the actual behavior. The caller can supply SKILL_TIER,
SKILL_DEPENDENCIES, SKILL_CAPABILITIES and SKILL_EFFECTS; lists are JSON
arrays. The result is one source package with SKILL.md and
scripts/validate.sh. The builder's report is
.agents/scratch/skill-builder/<slug>-build.json under
build-report.json.
Edit skills/<slug>/ as the source owner. Check the completed source with
scripts/heal.sh --check --strict skills/<slug>, then regenerate its owned
projections through the repository's owning commands. scripts/regen-all.sh
is the integrated projection recipe; scripts/generate-skill-mesh.py,
scripts/codex-sync.sh --only <slug> and
scripts/regen-codex-hashes.sh --only <slug> are the existing scoped surfaces.
Do not repeat work already performed by build.sh unless source changes
require it. Inspect the generated diff; hand-edit no projection.
Check/heal targets must be real direct children of skills/; reject missing
paths, traversal and symlink spellings. Check mode is read-only. Fix mode
regenerates owned projections for explicit targets and does not invent source
behavior. Findings name their code, target and concrete issue; --strict
returns nonzero for findings. Check the slug/name match, description, API
version, metadata, live dependencies and linked resources.
Deep audit reports structural and advisory authoring findings; it is not a candidate verdict. Its optional JSON follows audit-report.json. Interpret static scores as structure and authoring signals, not proof that a skill works. Exact checks live in audit checks, authoring doctrine, and Codex parity.
Conversion
Use bash skills/skill-builder/scripts/converter/convert.sh <skill-dir> <target> [output-dir] for an explicit out-of-tree export. Targets are codex, cursor
and test; --all selects all source packages, and --codex-layout inline
selects the legacy inline Codex layout. Read
SkillBundle when format details
matter. Parse the source once, render the target, then validate resource parity
and target format. Report layout and any omitted Cursor references.
The default export is .agents/projections/converter/<target>/<skill-name>/.
The exporter clean-writes its output directory, so use only the explicit derived
target: refuse a source package, its ancestor, or the repository root. Preserve
the source unchanged and fix the source or adapter instead of editing output.
A parse, write, format or required-resource failure leaves an incomplete export.
The shipped skills-codex/** remains owned by scripts/codex-sync.sh through
scripts/regen-all.sh; this ad-hoc exporter never replaces that authority.
Distill expertise
When the caller wants a reusable rule, begin with cited occurrences or a named authoritative source. State the trigger, desired behavior, inputs, outputs, negative example and limits. Prefer an addition to an existing reference or skill over a new root, library, gate or workflow; no action is a valid result.
An abstraction needs three independently evidenced real occurrences and a successful reapplication to a source case without missing context. Preserve short source excerpts or command results with resolvable citations. Fewer occurrences support a narrow reference note; an authoritative source substitutes only for a faithful statement of that source, not a wider generalization. Use Research's pattern mode when the claim needs exemplars and a holdout before packaging.
A proposed process artifact must have a concrete consumer, a subject or release decision it informs, an observed defect and a retirement condition. If any is missing, omit the artifact. Code written only to consume it supplies no consumer. Minimal recovery state needs a named evidence-loss or corruption risk. Show a negative/holdout case and how the proposed rule returns the right decision.
Return the proposal inline unless a durable proposal was requested. Respect Memory's source and destination rules for mined material. Evidence cannot publish itself as policy. Build an artifact only when the caller's authorization includes adoption; a proposal-only request ends with the proposal. Repair ordinary known defects within existing authority; tool failures remain explicit facts for the native caller, not an automatic helper chain.
For an actual package edit, use the source template for required fields and context density guidance when deciding which prose earns a place. Neither requires adding a new skill.
Files (agentops)
-
references
-
converter
-
skill-bundle-schema.md 3.2 KB
# SkillBundle Interchange Format The SkillBundle is the universal intermediate representation produced by the converter's parse stage. Every target adapter consumes a SkillBundle and transforms it into platform-specific output. ## Schema ```yaml SkillBundle: name: string # from frontmatter 'name' field description: string # from frontmatter 'description' field body: string # markdown content after frontmatter (closing --- to EOF) references: # files found in references/ directory - name: string # filename (e.g. 'output-format.md') content: string # full file content scripts: # files found in scripts/ directory - name: string # filename (e.g. 'validate.sh') content: string # full file content frontmatter: object # full parsed YAML frontmatter as key-value pairs ``` ## Field Details ### name (string, required) The skill's short name, extracted from the `name` field in SKILL.md YAML frontmatter. Example: `council`, `validate`, `plan` ### description (string, required) The skill's description, extracted from the `description` field in SKILL.md frontmatter. May contain trigger lists and usage summaries. ### body (string, required) The full markdown content of SKILL.md after the closing `---` of the frontmatter block. This is the skill's instructions, workflow documentation, and inline agent definitions. ### references (array of objects) Each file in the skill's `references/` directory becomes one entry: - **name**: The filename without path prefix (e.g. `output-format.md`) - **content**: The complete file contents as a string If no `references/` directory exists, this is an empty array. ### scripts (array of objects) Each file in the skill's `scripts/` directory becomes one entry: - **name**: The filename without path prefix (e.g. `validate.sh`) - **content**: The complete file contents as a string If no `scripts/` directory exists, this is an empty array. ### frontmatter (object) The complete parsed YAML frontmatter as a flat or nested key-value structure. This includes all fields -- not just `name` and `description` -- so target adapters can access `metadata.tier`, `metadata.dependencies`, and any custom fields. Example: ```yaml frontmatter: name: council description: 'Multi-model consensus council...' metadata: tier: orchestration dependencies: - standards replaces: judge ``` ## Usage in Target Adapters Target adapters receive the SkillBundle and decide which fields to use: | Adapter | Fields Used | Notes | |---------|-------------|-------| | codex | name, description, body, references, scripts | Emits `SKILL.md` + `prompt.md`; modular by default (copies + links resources), `--codex-layout inline` appends them | | cursor | name, description, body, references, scripts | Emits a single `<name>.mdc` rule (+ optional `mcp.json`), budget-fitted to 100KB | | test | all | Dumps the full bundle as structured markdown for inspection | ## Serialization The SkillBundle is an in-memory structure passed between pipeline stages. When written to disk (e.g. by the `test` target), it is rendered as structured markdown with clear section headers for each field.
-
-
audit-checks.md 5.3 KB
# Deep Skill Audit Checks `audit.sh` runs the structural `heal.sh --check --strict` pass, eight content checks, an advisory static package-readiness score, and advisory craft instrumentation. The checks protect usability without rewarding ceremony or package size. Executable thresholds and severities come from `skills/skill-builder/references/skill-conformance-profiles.yaml`. ## Verdicts | Severity | Result | |---|---| | FAIL | The skill contract is incomplete or unsafe. | | WARN | A concrete usability issue should be reviewed. | | PASS | No configured defect was found. | `--strict` makes WARN exit nonzero. Advisory scoring never changes the verdict. ## Checks ### `description-has-triggers` and `trigger-clarity` (WARN) The frontmatter description must state when the skill should load. Accepted forms are an inline or block `Triggers:` / `Use when:` marker, or—for the first check only—a `metadata.triggers` list accepted by the active profile. ### `constraints-frontloaded` (WARN) Skills longer than 100 lines need an early `Constraints` or `⚠️` section within the first 80 body lines. Concise kernels pass without a ceremonial section because their boundaries are already visible in one read. ### `rationale-present` (WARN) When a constraints section contains bullets, at least half should explain why the constraint exists. A skill with no constraint bullets passes this check. ### `verification-checkpoints` (WARN) A workflow with two or more named subphases should contain a checkpoint or an explicit verify-before boundary. One-step procedures pass without a checkpoint. ### `output-spec-explicit` (FAIL) A nonempty frontmatter `output_contract` passes. Skills without that AgentOps field must instead provide one output section containing every component required by the selected profile. This lets small inline adapters declare a sentence-shaped result without inventing an artifact directory, filename, schema, validator, and downstream controller. ### `quality-rubric` (WARN) Skills longer than 100 lines need at least three bullets under `Quality`, `Checks`, `Checklist`, `Rubric`, `Best Practices`, or `Acceptance`. Concise kernels pass because their evidence and stop conditions are directly visible. ### `references-modularization` (WARN) The canonical repo-runtime kernel limit is 250 lines. Move genuinely detailed material into linked references instead of expanding the always-loaded kernel. ## Craft instrumentation (Pass 4, advisory) `craft_score.py` adds three advisory blocks to the report. None of them ever changes the verdict or exit code; they name gaps for the author and the fresh validator to judge. - **Craft score** — presence of the 12 craft elements enumerated in [skill-template.md](skill-template.md) section 7, reported as `craft n/12; missing: <element-ids>`. Detection is cheap pattern matching over authored prose (HTML comments are stripped, so `init.sh` scaffold stubs never count). Presence, never quality. - **Provenance resolution** — repo paths and `.agents/ao` verdict/intent digest citations (full or abbreviated `prefix...suffix`) extracted from prose must resolve against the repository; each dead citation is a named finding. Fenced code blocks are treated as examples, not citations. - **Loop safety** — any section with iteration prose (`repeat`, `iterate`, `loop`) must contain a checkable stop-condition phrase (`stop after`, `at most N`, `until ... exit 0`); an agent-dispatch loop must also carry a budget phrase. Vague goals ("until it feels done") do not count as stop conditions. The scorer's detection power is itself mutation-tested: ```bash bash skills/skill-builder/scripts/test-craft-mutations.sh ``` ## Authoring prose scan (Pass 5, advisory) `authoring_scan.py` adds an advisory `authoring` block naming mechanical suspects for three failure modes from [authoring-doctrine.md](authoring-doctrine.md). Like density and craft, it never changes the verdict or exit code — the no-op test is model-relative and prohibitions are sometimes correct guardrails, so a human (or fresh validator) owns the judgment. - **`noop-phrase`** — phrasing the model already obeys by default ("be thorough", "make sure to", "carefully"), reported with the offending line. The fix is a sharper, behavior-changing instruction, not a louder wish. - **`negation-without-positive`** — a bullet/paragraph whose every clause prohibits ("Never edit generated files.") with no positive counterpart in the same unit. Pairing the prohibition with the target behavior ("Edit the source and regenerate; never edit generated files directly.") clears it. - **`step-missing-done-condition`** — a `###` subphase under a Workflow/Process/Methodology/Execution section with no checkable done-condition phrasing ("Done when", "Checkpoint:", "Stop after", "until ... exit 0"). One finding per offending subphase. Detection power is mutation-tested: ```bash bash skills/skill-builder/scripts/test-authoring-mutations.sh ``` ## Calibration rule Before tightening a check, run it across every canonical skill. A proposed rule that fails valid concise skills is miscalibrated unless the repository contract itself requires those skills to change. Do not add boilerplate solely to satisfy a heuristic. ```bash for skill in skills/*; do [[ -f "$skill/SKILL.md" ]] || continue bash skills/skill-builder/scripts/audit.sh "$skill" >/dev/null done ``` -
authoring-doctrine.md 5.3 KB
# Skill Authoring Doctrine Principles for writing `SKILL.md` bodies so an agent follows the same *process* on every run. Structure (frontmatter, sections, output contracts) lives in [skill-template.md](skill-template.md) and [audit-checks.md](audit-checks.md). This reference governs the sentences inside that structure. The deep audit's advisory `authoring` block (see [audit-checks.md](audit-checks.md)) mechanically flags three detectable failure modes; everything else is author judgment. Idea provenance: distilled clean-room from the skill-authoring ideas at <https://github.com/mattpocock/skills> (MIT). Concepts only — no upstream prose, names, prompts, scripts, or examples are reproduced here. ## 1. The no-op test A sentence belongs in the skill only when it alters what the agent would otherwise do. Vague intensifiers such as "be thorough" fail: they spend tokens without changing the default. Prefer a concrete, observable instruction ("read every `references/*.md` before editing") or a single strong pretrained cue the model already knows how to obey. Whether a line is load-bearing depends on the model. Authors who disagree should settle it by executing the skill, not by debating intent. Because of that relativity, the audit's `noop-phrase` finding is advisory: it names suspects from a fixed phrase list; the author decides. ## 2. Negation Telling the agent what to avoid tends to surface the forbidden pattern in context and raise its salience. Prefer naming the desired behavior ("write one-line comments") so the unwanted pattern is never primed. Hard bans remain valid when nothing positive can replace them (this repository's ban on `claude -p` is one). Even then, put the recovery path in the same paragraph — ban plus what to do instead — which matches this repo's "Anti-pattern: X. Corrective: Y" habit. The audit's `negation-without-positive` finding flags a paragraph where every clause forbids and none directs. ## 3. Completion criteria End each workflow step on a condition the agent can evaluate, and make that condition cover the whole obligation. Two properties matter: - **Checkable** — done vs not-done is decidable. "Understanding reached" is not; "every changed path appears in the manifest" is. - **Exhaustive** — the bar covers the full duty. "Produce a change list" allows a partial list; "every modified file accounted for" does not. A fuzzy exit invites *premature completion*: attention drifts from the work to finishing, and the step ends early. Tighten the exit criterion before restructuring the skill — that is the cheap local fix. The audit's `step-missing-done-condition` finding flags workflow subphases that lack any done phrasing ("Done when", "Checkpoint:", "Stop after", "until … exit 0"). ## 4. Leading words A leading word is one pretrained concept token that holds a behavioral region: *tight* (loop), *red* (failing check), *fresh* (context), *frontier*, *quarantine*. Used as a token — not re-explained each time — it builds a shared meaning across the skill and leans on priors the model already has, buying behavior for almost no context cost. Look for restated qualities that want collapsing: three near-synonyms for "fast and deterministic" are one idea; *tight* says it once. Invented terms need a single clear definition; pretrained words are free. A leading word that does not beat the model's default is itself a no-op — strengthen the word rather than adding more prose. ## 5. Description discipline The frontmatter description loads every turn, so prune it hardest. It has two jobs: name what the skill does, and list genuinely distinct trigger branches. One trigger per branch — synonym padding for the same branch is duplication that burns context and dulls matching. Prefer trigger wording callers actually use, especially shared leading words, so the description aligns with real prompts. This sharpens, rather than replaces, the structural `description-has-triggers` and `trigger-clarity` checks. ## 6. Context load vs cognitive load Every skill bills one of two accounts. A model-discoverable skill keeps its description in the window every turn — a standing **context load** whether or not it fires. A human-only skill costs nothing per turn but spends **cognitive load**: the human indexes which skills exist and when to call them. Choosing the account is a required authoring decision. This repository already exposes the levers: `user-invocable` frontmatter, `tier` and `disposition` metadata, and the generated router (`docs/SKILL-ROUTER.md`) that reduces cognitive load once human-reached skills multiply. Prefer model discovery only when autonomous reach or cross-skill invocation is required; otherwise keep the window clean. Splitting one skill into several also spends one of those loads — split only when a distinct trigger vocabulary or independently reachable behavior pays for the new entry. ## Applying the doctrine When creating or healing a skill, walk the body once per principle: remove no-op lines, convert bare bans into positive targets (keeping paired guardrails), give each workflow step a checkable exhaustive exit, collapse restatements into leading words, prune the description to one trigger per branch, and confirm the intended load account. The advisory `authoring` audit block names the mechanical suspects; the author owns the judgment calls. -
codex-parity.md 2.3 KB
# Codex Parity Repair Use this workflow when `skills/<name>/SKILL.md` is canonically correct but `skills-codex/<name>/SKILL.md` has drifted into bad Codex UX in the checked-in runtime artifact. ## Principles 1. `skills/<name>/SKILL.md` remains the canonical workflow contract. 2. `skills-codex/<name>/` is the checked-in Codex runtime artifact and may need direct maintenance. 3. Durable Codex-only body edits that should survive broader refactors belong in `skills-codex-overrides/<name>/SKILL.md`. 4. Codex operator-layer prompt edits belong in `skills-codex-overrides/<name>/prompt.md`. ## Audit First Run: ```bash bash scripts/audit-codex-parity.sh ``` Or target one skill: ```bash bash scripts/audit-codex-parity.sh --skill swarm ``` The audit flags the failure classes that Codex maintenance keeps missing today: - Claude-era task primitives - Claude-only backend reference names and team terminology - duplicated runtime phrases created by blind search/replace ## Repair Loop For each flagged skill: 1. Read `skills/<name>/SKILL.md` to confirm whether the canonical contract is correct. 2. Read `skills-codex/<name>/SKILL.md` to see the broken checked-in Codex body. 3. Read `skills-codex-overrides/<name>/prompt.md` and `skills-codex-overrides/catalog.json`. 4. If the source contract is wrong, fix `skills/<name>/SKILL.md` first. 5. If the shipped Codex artifact is wrong, update `skills-codex/<name>/SKILL.md`. 6. If the source is correct but Codex needs a durable tailoring layer, create or update `skills-codex-overrides/<name>/SKILL.md`. 7. Re-run validation: - `bash scripts/audit-codex-parity.sh` - `bash scripts/validate-codex-generated-artifacts.sh --scope worktree` - `bash scripts/validate-codex-override-coverage.sh` ## LLM Repair Guidance When doing the actual rewrite, the LLM should: - preserve the behavior contract from `skills/<name>/SKILL.md` - remove Claude-only primitive/tool names from the Codex body - replace mechanical rewrites with real Codex-native instructions - keep durable Codex-only delta in `skills-codex-overrides/<name>/SKILL.md` when it should remain distinct from the checked-in artifact If a skill keeps needing Codex-only body surgery, update `skills-codex-overrides/catalog.json` so the treatment matches reality instead of pretending the skill is still parity-only. -
context-density-checks.md 1.7 KB
# Advisory Context Density Checks This reference defines the density block of skill-builder's deep audit mode (absorbed from the retired `/skill-auditor`). It is report-only. It helps reviewers find skill prose that does not carry one of the six Context Density Rule fields before that prose is passed into a fresh context session. ## Fields | Field | Meaning | Advisory signals | |---|---|---| | `intent` | What behavior or capability the skill is trying to produce | intent, goal, behavior, capability | | `boundary` | Where the work starts/stops | boundary, bounded context, write scope, non-goal | | `evidence` | How the skill knows work is true or complete | evidence, test, verdict, validation, acceptance | | `decision` | Why this approach was chosen | decision, rationale, why, because, chosen | | `constraint` | Limits, safety rails, and non-negotiables | constraint, guardrail, limit, scope | | `next_action` | The next command, artifact, or handoff | next action, next steps, completion marker | ## Behavior - Missing fields produce `density.status: "warn"`. - Missing fields do not change the aggregate audit verdict. - Missing fields are not CI failures. - False positives should be recorded as findings or bead notes before any check is promoted. - This check does not satisfy execution-packet enforcement. The packet-boundary invariant is owned by `soc-2c1p.1`. ## Runnable Examples ```bash bash skills/skill-builder/scripts/audit.sh skills/plan bash skills/skill-builder/scripts/audit.sh skills/implement bash skills/skill-builder/scripts/audit.sh skills/validate ``` The expected result is a JSON `density` object with six `fields[]` entries. The field count is the contract; the individual pattern matches are advisory. -
heal.feature 1.2 KB · in bundle
-
skill-auditor.feature 1.3 KB · in bundle
-
skill-builder.feature 1.4 KB · in bundle
-
skill-conformance-profiles.yaml 4.5 KB
version: 1 default_profile: repo-runtime profiles: repo-runtime: id: repo-runtime kernel_max_lines: 250 trigger_forms: accepted: - inline-marker - block-marker - metadata-list description_markers: - "Triggers:" - "Use when:" metadata_list_min_items: 3 output_contract: section_headings: - Output - Output Specification - Output Format - Deliverables - Returns required_components: artifact-path: markers: - "artifact directory" - "**path:**" - ".agents/" - "stdout" filename-convention: markers: - "filename convention" - "**filename:**" serialization-schema: markers: - "serialization/schema format" - "**format:**" - "schema" validator-command: markers: - "validator command" - "**exit code:**" - "validation command" - "validate with" downstream-handoff: markers: - "downstream handoff" - "consumed by" - "confirming the fixture loaded" clean_room: enabled: true external_content_policy: observe-structure-only prohibited_copy_categories: - prose - prompts - scripts - examples - names copy_detection: minimum_fragment_characters: 24 minimum_name_characters: 8 protected_frontmatter_fields: - description ignored_exact_lines: - "---" ignored_line_prefixes: - "#" rule_order: - description-has-triggers - constraints-frontloaded - rationale-present - verification-checkpoints - output-spec-explicit - quality-rubric - references-modularization - trigger-clarity rules: description-has-triggers: severity: WARN accepted_forms: - inline-marker - block-marker - metadata-list constraints-frontloaded: severity: WARN rationale-present: severity: WARN verification-checkpoints: severity: WARN output-spec-explicit: severity: FAIL quality-rubric: severity: WARN references-modularization: severity: WARN trigger-clarity: severity: WARN accepted_forms: - inline-marker - block-marker external-observation: id: external-observation kernel_max_lines: 250 trigger_forms: accepted: - inline-marker - block-marker - metadata-list description_markers: - "Triggers:" - "Use when:" metadata_list_min_items: 3 output_contract: section_headings: - Output - Output Specification - Output Format - Deliverables - Returns required_components: filename-convention: markers: - "filename convention" - "**filename:**" serialization-schema: markers: - "serialization/schema format" - "**format:**" - "schema" clean_room: enabled: true external_content_policy: observe-structure-only prohibited_copy_categories: - prose - prompts - scripts - examples - names copy_detection: minimum_fragment_characters: 24 minimum_name_characters: 8 protected_frontmatter_fields: - description ignored_exact_lines: - "---" ignored_line_prefixes: - "#" rule_order: - description-has-triggers - constraints-frontloaded - rationale-present - verification-checkpoints - output-spec-explicit - quality-rubric - references-modularization - trigger-clarity rules: description-has-triggers: severity: WARN accepted_forms: - inline-marker - block-marker - metadata-list constraints-frontloaded: severity: WARN rationale-present: severity: WARN verification-checkpoints: severity: WARN output-spec-explicit: severity: FAIL quality-rubric: severity: WARN references-modularization: severity: WARN trigger-clarity: severity: WARN accepted_forms: - inline-marker - block-marker -
skill-template.md 10.3 KB
# Unified SKILL.md Template + Auditor Checklist > **Authority:** Executable rule IDs, severities, trigger forms, output-handoff > requirements, clean-room behavior, and the 250-line limit come from > [skill-conformance-profiles.yaml](skill-conformance-profiles.yaml). This is the canonical template `skill-builder` materializes and its deep audit mode validates against. Two artifacts in one document because both modes need identical truth. This template governs *structure*. The prose inside that structure is governed by [authoring-doctrine.md](authoring-doctrine.md) — the no-op test, negation, completion criteria, leading words, description discipline, and the context load vs cognitive load decision. Apply both when authoring or healing a skill. --- ## 1. Canonical SKILL.md template ```markdown --- name: <slug-with-hyphens> description: | <one-line: verb + object + domain> **Use when:** - <Trigger 1> - <Trigger 2> **Perfect for:** - <Scenario 1> **Not ideal for:** - <Anti-scenario 1> skill_api_version: 1 user-invocable: <true|false> context: window: <isolated|fork|inherit> intent: mode: <none|task|questions> sections: exclude: [HISTORY] metadata: tier: <judgment|execution|library|session|product|contribute|meta|background|orchestration|cross-vendor|knowledge> dependencies: [<other-skill-names>] stability: <experimental|stable> output_contract: <path-to-schema-or-description> --- # <Title matching slug> <1-2 sentence purpose paragraph> ## Overview / When to Use <Detailed explanation of what + why + when> ## ⚠️ Critical Constraints <Safety rules, data quality, governance — front-loaded, NOT buried> - **Rule N:** <constraint>. **Why:** <rationale tied to a real consequence> ## Workflow / Methodology <Step-by-step with verification checkpoints between phases> ### Phase 1: <name> <instructions> **Checkpoint:** <what to confirm before next phase> ### Phase 2: <name> ... ## Output Specification <Complete executable handoff> **Artifact directory:** <directory or path> **Filename convention:** <naming convention> **Serialization/schema format:** <format and schema> **Validator command:** <exact validator invocation> **Downstream handoff:** <consumer or next workflow> ## Quality Rubric <Checklist of deliverable criteria + sanity checks + common mistakes> - [ ] <Check 1> - [ ] <Check 2> ## Examples <Usage scenarios> ## Troubleshooting | Problem | Cause | Solution | |---------|-------|----------| ## See Also / References <Cross-skill links + references/*.md links> ``` --- ## 2. Auditor 15-check checklist Audits run in **two passes**. Pass 1 runs `heal.sh --check --strict` for structural hygiene and gates on the exit code. Pass 2 adds 8 NEW checks not covered by heal. ### Pass 1 — delegated to heal.sh (7 checks) | Check | heal.sh code | Severity | |-------|--------------|----------| | Frontmatter `name` present | MISSING_NAME | FAIL | | Frontmatter `description` present | MISSING_DESC | FAIL | | `name` matches directory | NAME_MISMATCH | FAIL | | All `references/*.md` linked from SKILL.md | UNLINKED_REF | WARN | | References point at existing files | DEAD_REF | WARN | | Scripts referenced exist | SCRIPT_REF_MISSING | WARN | | User-invocable in dispositions ledger | MISSING_DISPOSITION | FAIL in strict mode | ### Pass 2 — 8 NEW checks beyond heal.sh > The check id `description-has-triggers` (NOT `description-multiline`) is the canonical name. AgentOps' established convention is single-line `description: '...'`; the auditor must NOT false-fail that style. Three valid forms are accepted (any one passes). | # | Check id | What passes | Severity | |---|----------|-------------|----------| | 1 | `description-has-triggers` | A profile-accepted marker inside the description value, or a metadata list meeting the profile minimum | Profile | | 2 | `constraints-frontloaded` | Kernels over 100 lines put `Constraints` or `⚠️` within the first 80 body lines; concise kernels pass | Profile | | 3 | `rationale-present` | Each constraint bullet contains `why`, `because`, `this matters`, or similar rationale token | Profile | | 4 | `verification-checkpoints` | If skill has multi-phase Workflow/Methodology, body contains `Checkpoint`, `confirm`, or `Wait for` markers between phases | Profile | | 5 | `output-spec-explicit` | Nonempty `output_contract`, or one output section supplies every profile-required component | Profile | | 6 | `quality-rubric` | Kernels over 100 lines have 3+ bullets under Quality/Checks/Checklist/Rubric/Best Practices/Acceptance; concise kernels pass | Profile | | 7 | `references-modularization` | SKILL.md is at or below the profile's 250-line kernel limit | Profile | | 8 | `trigger-clarity` | Frontmatter `description` contains a profile-accepted marker an LLM can match | Profile | ### Verdict rule ``` fails > 0 → FAIL warns > 0 → WARN otherwise → PASS ``` --- ## 3. PRODUCT.md alignment mapping Each NEW Pass-2 check maps to AgentOps' design principles in PRODUCT.md, so the auditor enforces architectural intent rather than arbitrary stylistic preferences. | Auditor check | PRODUCT.md anchor | |---------------|-------------------| | `constraints-frontloaded` (⚠️ near top) | Operational Principle #6 (atomic changes compose) — constraint visibility prevents large rework | | `rationale-present` | Operational Principle #1 (agents are ephemeral; system carries state) — rationale must be inside the artifact, not in human memory | | `verification-checkpoints` | Operational Principle #5 (two-tier execution) — checkpoints prevent worker drift between phases | | `output-spec-explicit` | Pillar #4 (Kubernetes control loops) — declared state must be machine-readable | | `quality-rubric` | Operational Principle #3 (context quality determines output quality) | | `references-modularization` | Finding `f-2026-05-01-025` (SKILL.md churn budget — every Skill() invocation reloads 5-15KB) | | `trigger-clarity` | Operational Principle #1 (agents are ephemeral) — invocation criteria must be in artifact | | `description-has-triggers` (renamed from `description-multiline`) | Product surfaces — structured invocation criteria make the right capability discoverable without loading every skill body. Three valid forms preserve AgentOps' single-line convention. | --- ## 4. Section spine — REQUIRED order ``` H1 title └── Overview / When to Use └── ⚠️ Critical Constraints ← MUST appear within first 80 lines after frontmatter └── Workflow / Methodology ← MUST contain checkpoints between phases when multi-phase └── Output Specification ← MUST include the complete executable handoff └── Quality Rubric ← MUST contain 3+ bullets └── Examples └── Troubleshooting └── See Also / References ``` The `## Examples` and `## Troubleshooting` sections are recommended but not enforced (heal.sh catches missing references; the auditor leaves these to taste). --- ## 5. Frontmatter requirements (cross-reference) Validates against `schemas/skill-frontmatter.v1.schema.json`. Required fields: - `name` (string, lowercase-hyphen, must match directory) - `description` (string, see check #1 for accepted forms) - `skill_api_version` (integer, const: 1) Plus expected: - `metadata.tier` (one of the 11 enum values) - `context.window` (one of: `isolated`, `fork`, `inherit`) - `output_contract` (path to JSON Schema or description string) --- ## 6. Codex parity contract (per learning `2026-05-03-codex-skill-shape-is-dual-file`) For every shipped AgentOps skill, both files must exist: - `skills-codex/<name>/SKILL.md` — slim frontmatter (NO `skill_api_version`) - `skills-codex/<name>/prompt.md` — short Execution Profile (~10-20 lines) `scripts/audit-codex-parity.sh` is a content scanner; it will NOT catch frontmatter shape violations. Explicit grep checks (no `skill_api_version:` in codex SKILL.md; `prompt.md` exists) belong in the skill's own validation block. --- ## 7. The 12 craft elements (advisory Pass-4 instrumentation) `scripts/craft_score.py` detects the presence of these 12 authoring elements (never their quality) and reports an advisory `craft n/12` with named gaps in the deep audit. `scripts/init.sh` scaffolds one `<!-- craft:<id> ... -->` stub per element; the scorer strips HTML comments, so stubs never satisfy an element — only authored prose counts. | # | Element id | One-line authoring prompt | |---|------------|---------------------------| | 1 | `causal-insight-line` | State the one causal insight that makes this skill work (`Insight:` / `**Why:**` / a `because` clause). | | 2 | `named-failure-mode` | Name the concrete failure mode this skill exists to prevent (`fails when`, `failure mode`, a Failure behavior section). | | 3 | `frozen-prompts` | Provide any reusable prompt as a fenced block marked copy-paste-only. | | 4 | `named-loop-stop-condition` | If the skill iterates, name the loop and give a checkable stop condition in the same section (`stop after`, `at most N passes`, `until ... exit 0`). | | 5 | `quantified-rules` | Quantify at least one rule with a number and unit (`at most 3 attempts`, `250 lines`). | | 6 | `negative-space` | State what this skill is NOT for (`non-goals`, `not for`, `do not use when`). | | 7 | `anti-pattern-with-corrective` | Pair each anti-pattern with its corrective in the same section (`avoid X; instead Y`). | | 8 | `provenance-citation` | Cite at least one resolvable source: a repo path or a `.agents/ao` verdict/intent digest (abbreviated `prefix...suffix` accepted). | | 9 | `measurable-done` | Give a machine-checkable done signal (`done when`, `exit 0`, a validator command). | | 10 | `router-shape` | Map trigger phrases to modes/entry points in a routing table or Modes section. | | 11 | `trigger-rich-description` | Put `Triggers:` / `Use when` phrases callers actually say in the frontmatter description. | | 12 | `runnable-commands` | Include at least one fenced block with runnable commands. | --- ## 8. Out-of-scope for v1 (stocktake territory) The following deeper audits are described in a future `skills/skill-builder/references/skill-stocktake.md` but NOT yet implemented anywhere — defer to v2: - Actionability (does it produce concrete artifacts?) - Scope fit (right tier for the task?) - Uniqueness (overlap with other skills?) - Currency (referenced tools/APIs still current?) - LLM-decidable trigger clarity (deeper than `trigger-clarity` check above)
-
-
schemas
-
audit-report.json 17.4 KB
{ "$schema": "https://json-schema.org/draft-07/schema#", "title": "Skill Audit Report", "description": "Output contract for the skill-builder deep audit. Pass 1 wraps heal.sh structural checks; Pass 2 adds 8 content-discipline checks beyond heal.sh; Pass 3 reports an advisory 0-30 static package-readiness score that evaluates neither safety nor behavioral effectiveness; later passes add advisory craft and authoring signals.", "type": "object", "required": ["target", "profile_id", "verdict", "pass1", "pass2"], "properties": { "target": { "type": "string", "description": "skills/<name> path being audited" }, "profile_id": { "type": "string", "description": "Selected authoritative skill-conformance profile ID." }, "verdict": { "type": "string", "enum": ["PASS", "WARN", "FAIL"], "description": "Aggregate Pass-2 verdict. A nonzero Pass-1 exit additionally forces FAIL only for a repository-owned skills/* or skills-codex/* target; external targets retain Pass-1 diagnostics without binding the aggregate." }, "pass1": { "type": "object", "description": "heal structural results (delegated to bash skills/skill-builder/scripts/heal.sh --check --strict <target>).", "required": ["status", "exit_code", "findings"], "properties": { "status": { "type": "string", "enum": ["pass", "fail"], "description": "Strict heal verdict based on process exit code, not parsed finding text." }, "exit_code": { "type": "integer", "description": "Actual exit code from heal.sh --check --strict. Nonzero forces aggregate FAIL only when the target is under this repository's skills/* or skills-codex/* tree." }, "strict": { "type": "boolean", "const": true, "description": "Always true: Pass 1 uses heal strict mode." }, "findings": { "type": "array", "items": { "type": "object", "required": ["code", "path", "msg"], "properties": { "code": { "type": "string", "description": "heal.sh finding code (e.g., MISSING_NAME, UNLINKED_REF, DEAD_REF)" }, "path": {"type": "string"}, "msg": {"type": "string"} } } }, "autofixable": { "type": "integer", "description": "Count of findings heal.sh can auto-fix with --fix" } } }, "pass2": { "type": "object", "description": "Eight NEW checks beyond heal structural hygiene.", "required": ["checks"], "properties": { "checks": { "type": "array", "minItems": 8, "maxItems": 8, "items": { "type": "object", "required": ["id", "severity", "status"], "properties": { "id": { "type": "string", "enum": [ "description-has-triggers", "constraints-frontloaded", "rationale-present", "verification-checkpoints", "output-spec-explicit", "quality-rubric", "references-modularization", "trigger-clarity" ], "description": "Stable check identifier. NOTE: 'description-has-triggers' (NOT 'description-multiline') — the broader form accepts AgentOps' single-line description convention plus financial-services' multi-line block-scalar form plus metadata.triggers arrays. See skills/skill-builder/references/skill-template.md §2 for accepted forms." }, "status": { "type": "string", "enum": ["pass", "warn", "fail", "n/a"], "description": "Status derived from the selected profile severity." }, "severity": { "type": "string", "enum": ["WARN", "FAIL"] }, "forms": { "type": "array", "items": { "type": "string", "enum": ["inline-marker", "block-marker", "metadata-list"] } }, "evidence": { "type": "string", "description": "Specific finding (line, snippet, or grep match) supporting the status." } } } } } }, "density": { "type": "object", "description": "Advisory-only Context Density Rule coverage. This does not affect the aggregate verdict and does not enforce execution-packet density.", "required": ["status", "advisory", "fields", "summary"], "properties": { "status": { "type": "string", "enum": ["pass", "warn"], "description": "pass when all six advisory density fields are present, warn otherwise." }, "advisory": { "type": "boolean", "const": true, "description": "Always true. Density coverage is report-only at the skill-auditor layer." }, "fields": { "type": "array", "minItems": 6, "maxItems": 6, "items": { "type": "object", "required": ["id", "present", "evidence"], "properties": { "id": { "type": "string", "enum": [ "intent", "boundary", "evidence", "decision", "constraint", "next_action" ] }, "present": {"type": "boolean"}, "evidence": {"type": "string"} }, "additionalProperties": false } }, "summary": {"type": "string"} }, "additionalProperties": false }, "rubric": { "description": "Advisory-only Pass-3 static package-readiness score (docs/reference/skill-quality-rubric.md). Folded in from score_agentops_skill.py --audit-block. It evaluates neither the safety gate nor behavioral effectiveness and never affects the aggregate verdict. Emitted as null when python3 or the scorer is unavailable (fail-open).", "oneOf": [ {"type": "null"}, { "type": "object", "required": ["scope", "safety_gate_evaluated", "effectiveness_evaluated", "total_score", "max_score", "rating", "advisory", "categories"], "properties": { "scope": { "type": "string", "const": "static-package-readiness", "description": "The score covers visible package properties only." }, "safety_gate_evaluated": { "type": "boolean", "const": false, "description": "Always false: boundary-word heuristics are not a full-bundle safety review." }, "effectiveness_evaluated": { "type": "boolean", "const": false, "description": "Always false: structural scoring contains no baseline-versus-treatment behavioral evaluation." }, "total_score": { "type": "integer", "minimum": 0, "maximum": 30, "description": "Emitter-reported sum of the 10 static category scores (0-30). Draft-07 bounds this value; focused emitter tests verify the arithmetic." }, "max_score": { "type": "integer", "const": 30 }, "rating": { "type": "string", "enum": ["C", "B", "A", "S"], "description": "Emitter-reported static band: C (0-10), B (11-20), A (21-26), S (27-30). Focused emitter tests verify consistency with total_score." }, "advisory": { "type": "boolean", "const": true, "description": "Always true. The static readiness score is report-only and never gates the verdict." }, "categories": { "type": "array", "minItems": 10, "maxItems": 10, "description": "Exactly one entry for each of the 10 static package-readiness categories, each scored 0-3 with a deterministic reason. Absent optional components receive uncertainty score 1, never automatic full credit.", "allOf": [ {"contains": {"required": ["category"], "properties": {"category": {"const": "trigger_quality"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "kernel_clarity"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "progressive_disclosure"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "helper_scripts"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "validation"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "self_test"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "assets_templates"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "subagents_roles"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "safety_boundaries"}}}}, {"contains": {"required": ["category"], "properties": {"category": {"const": "packaging"}}}} ], "items": { "type": "object", "required": ["category", "score", "reason"], "properties": { "category": { "type": "string", "enum": [ "trigger_quality", "kernel_clarity", "progressive_disclosure", "helper_scripts", "validation", "self_test", "assets_templates", "subagents_roles", "safety_boundaries", "packaging" ] }, "score": { "type": "integer", "minimum": 0, "maximum": 3, "description": "0 missing and required; 1 weak or no visible evidence with necessity not inferred; 2 solid visible evidence; 3 mechanically strong or unusually complete." }, "reason": { "type": "string", "description": "Deterministic explanation derived from the skill directory contents." } }, "additionalProperties": false } } }, "additionalProperties": false } ] }, "craft": { "description": "Advisory-only Pass-4 craft instrumentation (craft_score.py): 12-element craft score with named gaps, provenance-citation resolution, and loop-safety findings. Additive and never affects the aggregate verdict or exit code. Emitted as null when python3 or the scorer is unavailable (fail-open).", "oneOf": [ {"type": "null"}, { "type": "object", "required": ["score", "max", "advisory", "missing", "elements", "provenance", "loop_safety", "summary"], "properties": { "score": { "type": "integer", "minimum": 0, "maximum": 12, "description": "Count of the 12 craft elements detected as present." }, "max": {"type": "integer", "const": 12}, "advisory": { "type": "boolean", "const": true, "description": "Always true. Craft instrumentation is report-only and never gates." }, "missing": { "type": "array", "items": {"type": "string"}, "description": "Element ids not detected; the named gaps." }, "elements": { "type": "array", "minItems": 12, "maxItems": 12, "items": { "type": "object", "required": ["id", "present", "evidence"], "properties": { "id": { "type": "string", "enum": [ "causal-insight-line", "named-failure-mode", "frozen-prompts", "named-loop-stop-condition", "quantified-rules", "negative-space", "anti-pattern-with-corrective", "provenance-citation", "measurable-done", "router-shape", "trigger-rich-description", "runnable-commands" ] }, "present": {"type": "boolean"}, "evidence": {"type": "string"} }, "additionalProperties": false } }, "provenance": { "type": "object", "required": ["advisory", "citations", "resolved", "dead"], "properties": { "advisory": {"type": "boolean", "const": true}, "citations": {"type": "integer", "minimum": 0}, "resolved": {"type": "integer", "minimum": 0}, "dead": { "type": "array", "items": { "type": "object", "required": ["citation", "kind"], "properties": { "citation": {"type": "string"}, "kind": {"type": "string", "enum": ["digest", "path"]} }, "additionalProperties": false } } }, "additionalProperties": false }, "loop_safety": { "type": "object", "required": ["advisory", "findings"], "properties": { "advisory": {"type": "boolean", "const": true}, "findings": { "type": "array", "items": { "type": "object", "required": ["type", "section", "evidence"], "properties": { "type": { "type": "string", "enum": ["loop-missing-stop-condition", "dispatch-loop-missing-budget"] }, "section": {"type": "string"}, "evidence": {"type": "string"} }, "additionalProperties": false } } }, "additionalProperties": false }, "summary": { "type": "string", "description": "One-line rollup, e.g. 'craft 7/12; missing: causal-insight-line, frozen-prompts'." } }, "additionalProperties": false } ] }, "authoring": { "description": "Advisory-only Pass-5 authoring prose-quality scan (authoring_scan.py): mechanical suspects for the failure modes named in references/authoring-doctrine.md. Never affects the aggregate verdict or exit code. Emitted as null when python3 or the scanner is unavailable (fail-open).", "oneOf": [ {"type": "null"}, { "type": "object", "required": ["advisory", "findings", "counts", "summary"], "properties": { "advisory": { "type": "boolean", "const": true, "description": "Always true. Authoring findings are report-only and never gate." }, "findings": { "type": "array", "items": { "type": "object", "required": ["id", "line", "evidence"], "properties": { "id": { "type": "string", "enum": [ "noop-phrase", "negation-without-positive", "step-missing-done-condition" ] }, "line": {"type": "integer", "minimum": 1}, "evidence": {"type": "string"} }, "additionalProperties": false } }, "counts": { "type": "object", "required": [ "noop-phrase", "negation-without-positive", "step-missing-done-condition" ], "properties": { "noop-phrase": {"type": "integer", "minimum": 0}, "negation-without-positive": {"type": "integer", "minimum": 0}, "step-missing-done-condition": {"type": "integer", "minimum": 0} }, "additionalProperties": false }, "summary": {"type": "string"} }, "additionalProperties": false } ] }, "summary": { "type": "string", "description": "Human-readable one-paragraph rollup. Suitable for embedding in a markdown audit report." } }, "additionalProperties": false } -
build-report.json 674 B
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "Skill Build Report", "type": "object", "required": ["mode", "skill_name", "files_created", "structure_check_pass"], "properties": { "mode": { "type": "string", "enum": ["from-scratch", "from-template", "absorb-external"] }, "skill_name": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, "files_created": { "type": "array", "items": {"type": "string"}, "minItems": 2, "uniqueItems": true }, "structure_check_pass": {"type": "boolean"}, "source_hint": {"type": "string"} }, "additionalProperties": false }
-
-
scripts
-
converter
-
convert.sh 24.9 KB
#!/usr/bin/env bash # convert.sh — Cross-platform skill converter pipeline # Usage: bash skills/skill-builder/scripts/converter/convert.sh <skill-dir> <target> [output-dir] # bash skills/skill-builder/scripts/converter/convert.sh --all <target> [output-dir] set -euo pipefail # This script uses namerefs (`local -n`), which require Bash >= 4.3. On stock # macOS /bin/bash (3.2.57) a nameref is an invalid option that aborts under # `set -e` with an opaque message and zero files written. Fail closed with a # clear diagnostic instead of an unrunnable surprise. if (( BASH_VERSINFO[0] < 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] < 3) )); then echo "ERROR: convert.sh requires Bash >= 4.3 (namerefs); found ${BASH_VERSION}." >&2 echo " On macOS install a newer bash ('brew install bash') and invoke it explicitly." >&2 exit 2 fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)" SKILL_PATTERN="" CODEX_LAYOUT="modular" ARGS_SKILL_DIR_OR_FLAG="" ARGS_TARGET="" ARGS_OUTPUT_DIR="" # ─── Helpers ─────────────────────────────────────────────────────────── die() { echo "ERROR: $*" >&2; exit 1; } usage() { cat <<'EOF' Usage: bash skills/skill-builder/scripts/converter/convert.sh [--codex-layout modular|inline] <skill-dir> <target> [output-dir] bash skills/skill-builder/scripts/converter/convert.sh [--codex-layout modular|inline] --all <target> [output-dir] Targets: codex, cursor, test Examples: bash skills/skill-builder/scripts/converter/convert.sh skills/council codex bash skills/skill-builder/scripts/converter/convert.sh --codex-layout inline skills/council codex bash skills/skill-builder/scripts/converter/convert.sh --all codex bash skills/skill-builder/scripts/converter/convert.sh skills/vibe test /tmp/out EOF exit 1 } parse_args() { local positional=() while [[ $# -gt 0 ]]; do case "$1" in --codex-layout) [[ $# -ge 2 ]] || die "--codex-layout requires a value: modular|inline" CODEX_LAYOUT="$2" shift 2 ;; --codex-layout=*) CODEX_LAYOUT="${1#*=}" shift ;; --all) positional+=("--all") shift ;; -h|--help) usage ;; --) shift while [[ $# -gt 0 ]]; do positional+=("$1") shift done break ;; -*) die "Unknown flag: $1" ;; *) positional+=("$1") shift ;; esac done [[ "$CODEX_LAYOUT" == "modular" || "$CODEX_LAYOUT" == "inline" ]] \ || die "Invalid --codex-layout '$CODEX_LAYOUT'. Expected: modular|inline" [[ ${#positional[@]} -ge 2 ]] || usage ARGS_SKILL_DIR_OR_FLAG="${positional[0]}" ARGS_TARGET="${positional[1]}" ARGS_OUTPUT_DIR="${positional[2]:-}" } yaml_escape_single_quote() { printf '%s' "$1" | sed "s/'/''/g" } # Build an alternation regex for all known skill names. load_skill_pattern() { local names=() local d for d in "$REPO_ROOT"/skills/*/; do [[ -f "$d/SKILL.md" ]] || continue names+=("$(basename "$d")") done # Some command aliases are valid in docs even when no source skill directory # exists in this repo (for example, migrated or generated-only skills). # Keep these in the rewrite pattern so slash forms still convert to $ forms. names+=("knowledge" "learn" "extract" "inbox") if [[ ${#names[@]} -eq 0 ]]; then SKILL_PATTERN="" return fi local escaped=() local name for name in "${names[@]}"; do escaped+=("$(printf '%s' "$name" | sed -E 's/[][(){}.^$*+?|\\-]/\\&/g')") done SKILL_PATTERN="$(IFS='|'; printf '%s' "${escaped[*]}")" } # Rewrite Claude-style slash command references to Codex-style dollar references. # Example: /plan -> $plan (for known skill names only). codex_rewrite_text() { local input="$1" local output="$input" if [[ -n "$SKILL_PATTERN" ]]; then output="$(printf '%s' "$output" | SKILL_PATTERN="$SKILL_PATTERN" perl -0pe ' my $pattern = qr/$ENV{SKILL_PATTERN}/; s{(?<![A-Za-z0-9_/])/($pattern)(?![A-Za-z0-9-])}{\$$1}g; ')" fi output="$(printf '%s' "$output" | perl -0pe ' s/\bClaude[ ]Code\b/Codex/g; s/\bClaude[ ]Native[ ]Teams\b/Codex sub-agents/g; s/\bClaude[ ]native[ ]team\b/Codex sub-agent/g; s/\bClaude[ ]teams\b/Codex sub-agents/g; s/\bclaude[ ]teams\b/codex sub-agents/g; s/\bClaude[ ]session(s)?\b/Codex session$1/g; s/\bclaude[ ]session(s)?\b/codex session$1/g; s/\bClaude[ ]runtime\b/Codex runtime/g; s/\bclaude[ ]runtime\b/codex runtime/g; s/\bClaude[ ]workers\b/Codex workers/g; s/\bclaude[ ]workers\b/codex workers/g; s/\bclaude-native-teams\b/codex-sub-agents/g; s{~/.claude/skills/}{~/.agents/skills/}g; s{~/.claude/}{~/.codex/}g; s{\$HOME/.claude/}{\$HOME/.codex/}g; s{/.claude/}{/.codex/}g; s{\.claude/}{.codex/}g; s/backend-claude-teams\.md/backend-codex-subagents.md/g; s/\bclaude agents\b/codex agents/g; # Map Claude tools to Codex tools s/\bthe Read tool\b/read_file/g; s/\bthe Edit tool\b/apply_patch/g; s/\bthe Grep tool\b/rg/g; s/\bthe Glob tool\b/glob_file_search/g; s/\bAgent\(subagent_type="Explore"/spawn a sub-agent (explorer role/g; s/\bsubagent_type:\s*"Explore"/role: explorer/g; # Rewrite Skill() tool invocations to $skill syntax s/Skill\(skill="([^"]+)"(?:,\s*args="([^"]*)")?\)/\$$1 $2/g; # Strip lines with Claude primitives (no Codex equivalent — empirically verified) s/.*\b(?:TaskCreate|TaskUpdate|TaskList|TaskGet|TaskStop)\b.*\n?//g; s/.*\b(?:TeamCreate|TeamDelete)\b.*\n?//g; s/.*\b(?:SendMessage)\b.*\n?//g; s/.*\b(?:EnterPlanMode|ExitPlanMode|EnterWorktree)\b.*\n?//g; s/.*\*\*USE THE TASK TOOL\*\*.*\n?//g; s/.*\bTool:\s*Task\b.*\n?//g; # Post-rewrite dedup: collapse doubled runtime phrases s/Codex sub-agents in Codex sessions, Codex sub-agents in Codex sessions/Codex sub-agents in Codex sessions/g; s/Codex session -> Codex sub-agents; Codex session -> Codex sub-agents/Codex session -> Codex sub-agents/g; ')" printf '%s' "$output" } # Deduplicate semantically equivalent Codex runtime headings while preserving # all section content. If multiple "In Codex" headings exist after rewrites, # keep the first heading and drop subsequent duplicate heading lines. codex_dedupe_runtime_headings() { local input="$1" printf '%s' "$input" | awk ' function norm(line, t) { t = tolower(line) gsub(/^[[:space:]]*#+[[:space:]]*/, "", t) gsub(/^[[:space:]]*\*\*[[:space:]]*/, "", t) gsub(/[[:space:]]*\*\*[[:space:]]*$/, "", t) gsub(/[[:space:]]*:[[:space:]]*$/, "", t) gsub(/^[[:space:]]+|[[:space:]]+$/, "", t) return t } { key = norm($0) if (key == "in codex") { if (seen[key] == 1) { next } seen[key] = 1 } print } ' } # ─── Stage 1: Parse ─────────────────────────────────────────────────── # Parse SKILL.md frontmatter and body. # Sets: BUNDLE_NAME, BUNDLE_DESC, BUNDLE_BODY, BUNDLE_FRONTMATTER parse_skill_md() { local skill_md="$1" [[ -f "$skill_md" ]] || die "SKILL.md not found: $skill_md" local content content="$(<"$skill_md")" # Extract frontmatter (between first and second --- lines) local in_fm=0 local fm_lines=() local body_lines=() local fm_ended=0 local line_num=0 while IFS= read -r line; do line_num=$((line_num + 1)) if [[ $line_num -eq 1 && "$line" == "---" ]]; then in_fm=1 continue fi if [[ $in_fm -eq 1 && "$line" == "---" ]]; then in_fm=0 fm_ended=1 continue fi if [[ $in_fm -eq 1 ]]; then fm_lines+=("$line") elif [[ $fm_ended -eq 1 ]]; then body_lines+=("$line") fi done <<< "$content" BUNDLE_FRONTMATTER="$(printf '%s\n' "${fm_lines[@]}")" # Extract name and description from frontmatter BUNDLE_NAME="$(echo "$BUNDLE_FRONTMATTER" | sed -n 's/^name: *//p' | tr -d "'" | tr -d '"')" BUNDLE_DESC="$( awk ' BEGIN { capture = 0 first = 1 } /^description:[[:space:]]*[>|]-?[[:space:]]*$/ { capture = 1 next } /^description:[[:space:]]*/ { sub(/^description:[[:space:]]*/, "", $0) gsub(/^'\''|'\''$/, "", $0) gsub(/^"|"$/, "", $0) print exit } capture { if ($0 ~ /^[^[:space:]]/ && $0 !~ /^$/) { exit } line = $0 sub(/^[[:space:]]+/, "", line) if (line == "") { next } if (!first) { printf " " } printf "%s", line first = 0 } ' <<< "$BUNDLE_FRONTMATTER" )" # Body: join with newlines BUNDLE_BODY="$(printf '%s\n' "${body_lines[@]}")" } # Collect files from a subdirectory into parallel arrays. # Args: <dir> <array-name-names> <array-name-contents> # Scope: top-level regular files of <dir> only. Nested reference/script files # are NOT inlined here; copy_passthrough_resources() preserves them recursively # in the written output, so nested resources survive a conversion even though # they are not flattened into the target SKILL.md body. collect_files() { local dir="$1" local -n names_arr="$2" local -n contents_arr="$3" names_arr=() contents_arr=() if [[ -d "$dir" ]]; then local f _old_lc="${LC_ALL:-}" LC_ALL=C for f in "$dir"/*; do [[ -f "$f" ]] || continue names_arr+=("$(basename "$f")") contents_arr+=("$(<"$f")") done LC_ALL="${_old_lc}" fi } # Full parse: populate all BUNDLE_* variables and REF/SCRIPT arrays parse_bundle() { local skill_dir="$1" parse_skill_md "$skill_dir/SKILL.md" collect_files "$skill_dir/references" REF_NAMES REF_CONTENTS collect_files "$skill_dir/scripts" SCRIPT_NAMES SCRIPT_CONTENTS } # ─── Stage 2: Convert ───────────────────────────────────────────────── # Test target: emit SkillBundle as structured markdown convert_test() { local out="" out+="# SkillBundle: ${BUNDLE_NAME}"$'\n\n' out+="## Name"$'\n\n' out+="${BUNDLE_NAME}"$'\n\n' out+="## Description"$'\n\n' out+="${BUNDLE_DESC}"$'\n\n' out+="## Frontmatter"$'\n\n' out+='```yaml'$'\n' out+="${BUNDLE_FRONTMATTER}"$'\n' out+='```'$'\n\n' out+="## Body"$'\n\n' out+="${BUNDLE_BODY}"$'\n\n' out+="## References (${#REF_NAMES[@]})"$'\n\n' local i for i in "${!REF_NAMES[@]}"; do out+="### ${REF_NAMES[$i]}"$'\n\n' out+='```'$'\n' out+="${REF_CONTENTS[$i]}"$'\n' out+='```'$'\n\n' done out+="## Scripts (${#SCRIPT_NAMES[@]})"$'\n\n' for i in "${!SCRIPT_NAMES[@]}"; do out+="### ${SCRIPT_NAMES[$i]}"$'\n\n' out+='```'$'\n' out+="${SCRIPT_CONTENTS[$i]}"$'\n' out+='```'$'\n\n' done CONVERTED_OUTPUT="$out" CONVERTED_FILENAME="bundle.md" } # Codex target: SKILL.md + prompt.md # Codex may load these skills from ~/.codex/skills or from a native plugin cache. # Description max 1024 chars, no hooks support, tool names pass through convert_codex() { local desc="$BUNDLE_DESC" local body body="$(codex_rewrite_text "$BUNDLE_BODY")" body="$(codex_dedupe_runtime_headings "$body")" # Truncate description to 1024 chars at word boundary if [[ ${#desc} -gt 1024 ]]; then desc="${desc:0:1021}" # Trim to last word boundary (space) desc="${desc% *}..." fi desc="$(codex_rewrite_text "$desc")" local desc_escaped desc_escaped="$(yaml_escape_single_quote "$desc")" # ── Build SKILL.md ── local skill_md="" skill_md+="---"$'\n' skill_md+="name: ${BUNDLE_NAME}"$'\n' skill_md+="description: '${desc_escaped}'"$'\n' skill_md+="---"$'\n\n' skill_md+="${body}"$'\n' if [[ "$CODEX_LAYOUT" == "inline" ]]; then # Inline references as appended sections (legacy/portable mode) if [[ ${#REF_NAMES[@]} -gt 0 ]]; then skill_md+=$'\n'"---"$'\n\n' skill_md+="## References"$'\n\n' local i for i in "${!REF_NAMES[@]}"; do skill_md+="### ${REF_NAMES[$i]}"$'\n\n' skill_md+="$(codex_rewrite_text "${REF_CONTENTS[$i]}")"$'\n\n' done fi # Inline scripts as code blocks (legacy/portable mode) if [[ ${#SCRIPT_NAMES[@]} -gt 0 ]]; then skill_md+=$'\n'"---"$'\n\n' skill_md+="## Scripts"$'\n\n' local i for i in "${!SCRIPT_NAMES[@]}"; do # Detect language from extension local ext="${SCRIPT_NAMES[$i]##*.}" local lang="" case "$ext" in sh|bash) lang="bash" ;; py) lang="python" ;; js) lang="javascript" ;; ts) lang="typescript" ;; *) lang="$ext" ;; esac skill_md+="### ${SCRIPT_NAMES[$i]}"$'\n\n' skill_md+="\`\`\`${lang}"$'\n' skill_md+="$(codex_rewrite_text "${SCRIPT_CONTENTS[$i]}")"$'\n' skill_md+="\`\`\`"$'\n\n' done fi else # Modular mode: keep SKILL.md concise and reference copied resources. if [[ ${#REF_NAMES[@]} -gt 0 || ${#SCRIPT_NAMES[@]} -gt 0 ]]; then skill_md+=$'\n'"## Local Resources"$'\n\n' local i if [[ ${#REF_NAMES[@]} -gt 0 ]]; then skill_md+="### references/"$'\n\n' for i in "${!REF_NAMES[@]}"; do skill_md+="- [references/${REF_NAMES[$i]}](references/${REF_NAMES[$i]})"$'\n' done skill_md+=$'\n' fi if [[ ${#SCRIPT_NAMES[@]} -gt 0 ]]; then skill_md+="### scripts/"$'\n\n' for i in "${!SCRIPT_NAMES[@]}"; do skill_md+="- \`scripts/${SCRIPT_NAMES[$i]}\`"$'\n' done skill_md+=$'\n' fi fi fi # ── Build prompt.md ── local prompt_md="" prompt_md+="# ${BUNDLE_NAME}"$'\n\n' prompt_md+="${desc}"$'\n\n' prompt_md+="## Instructions"$'\n\n' prompt_md+="Load and follow the skill instructions from the sibling \`SKILL.md\` file for this skill."$'\n' if [[ "$CODEX_LAYOUT" == "modular" && ( ${#REF_NAMES[@]} -gt 0 || ${#SCRIPT_NAMES[@]} -gt 0 ) ]]; then prompt_md+="Then read local files in \`references/\` and \`scripts/\` when needed."$'\n' fi # Set primary output (SKILL.md) CONVERTED_OUTPUT="$skill_md" CONVERTED_FILENAME="SKILL.md" # Set secondary output (prompt.md) CONVERTED_OUTPUT_2="$prompt_md" CONVERTED_FILENAME_2="prompt.md" } # Cursor target: .mdc rule file with YAML frontmatter + optional mcp.json # Cursor rules format: .cursor/rules/<name>.mdc (Cursor 0.40+) # Max output size: 100KB (102400 bytes). References are budget-fitted. CURSOR_MAX_BYTES=102400 convert_cursor() { local out="" # ── YAML frontmatter ── # Single-quote and escape the description: an unquoted value containing a # colon, quote, or leading special char yields invalid Cursor YAML (CV-9). out+="---"$'\n' out+="description: '$(yaml_escape_single_quote "$BUNDLE_DESC")'"$'\n' out+="globs: "$'\n' out+="alwaysApply: false"$'\n' out+="---"$'\n\n' # ── Body content ── out+="${BUNDLE_BODY}"$'\n' # ── Scripts as code blocks (included before references — smaller, higher value) ── if [[ ${#SCRIPT_NAMES[@]} -gt 0 ]]; then out+=$'\n'"## Scripts"$'\n\n' local i for i in "${!SCRIPT_NAMES[@]}"; do local ext="${SCRIPT_NAMES[$i]##*.}" local lang="" case "$ext" in sh|bash) lang="bash" ;; py) lang="python" ;; js) lang="javascript" ;; ts) lang="typescript" ;; *) lang="$ext" ;; esac out+="### ${SCRIPT_NAMES[$i]}"$'\n\n' out+="\`\`\`${lang}"$'\n' out+="${SCRIPT_CONTENTS[$i]}"$'\n' out+="\`\`\`"$'\n\n' done fi # ── Inline references (budget-fitted to stay under CURSOR_MAX_BYTES) ── if [[ ${#REF_NAMES[@]} -gt 0 ]]; then local current_size=${#out} local budget=$(( CURSOR_MAX_BYTES - current_size - 200 )) # 200 byte margin for section header + omission note local ref_section="" local omitted=0 local i ref_section+=$'\n'"## References"$'\n\n' for i in "${!REF_NAMES[@]}"; do local entry="" entry+="### ${REF_NAMES[$i]}"$'\n\n' entry+="${REF_CONTENTS[$i]}"$'\n\n' local entry_size=${#entry} if [[ $budget -ge $entry_size ]]; then ref_section+="$entry" budget=$(( budget - entry_size )) else omitted=$(( omitted + 1 )) fi done if [[ $omitted -gt 0 ]]; then ref_section+="*${omitted} reference(s) omitted to stay under 100KB size limit.*"$'\n\n' echo "WARN: ${BUNDLE_NAME}: omitted $omitted reference(s) to stay under 100KB" >&2 fi out+="$ref_section" fi CONVERTED_OUTPUT="$out" CONVERTED_FILENAME="${BUNDLE_NAME}.mdc" # ── MCP detection: scan body + references for MCP server references ── # If skill content references MCP servers, generate a stub mcp.json local all_content="${BUNDLE_BODY}" local i for i in "${!REF_CONTENTS[@]}"; do all_content+=$'\n'"${REF_CONTENTS[$i]}" done if echo "$all_content" | grep -qiE '(mcpServers|mcp_server|"mcp"|mcp\.json)'; then CONVERTED_OUTPUT_2='{ "mcpServers": {} }' CONVERTED_FILENAME_2="mcp.json" fi } run_convert() { local target="$1" case "$target" in test) convert_test ;; codex) convert_codex ;; cursor) convert_cursor ;; *) die "Unknown target: $target. Supported: codex, cursor, test" ;; esac } # ─── Stage 3: Write ─────────────────────────────────────────────────── copy_passthrough_resources() { local source_dir="$1" local output_dir="$2" local entry base # Preserve non-generated skill resources (e.g., templates/, assets/, schemas/, # examples/, agents/, and other auxiliary files) so converted skills retain # runnable/supporting artifacts beyond SKILL.md/prompt.md. while IFS= read -r -d '' entry; do base="$(basename "$entry")" case "$base" in SKILL.md|prompt.md) continue ;; esac # Copy and dereference symlinks to keep output plugin-compatible. if [[ -d "$entry" ]]; then # For directories (references/, scripts/, etc.), copy then rewrite .md files rsync -a --copy-links "$entry" "$output_dir"/ local subdir="$output_dir/$base" if [[ -d "$subdir" ]]; then while IFS= read -r md_file; do local content content="$(<"$md_file")" local rewritten rewritten="$(codex_rewrite_text "$content")" if [[ "$rewritten" != "$content" ]]; then printf '%s\n' "$rewritten" > "$md_file" fi done < <(find "$subdir" -name '*.md' -type f 2>/dev/null) fi else rsync -a --copy-links "$entry" "$output_dir"/ # Rewrite top-level .md files too (e.g., validation-contract.md) if [[ "$entry" == *.md ]]; then local out_file="$output_dir/$base" if [[ -f "$out_file" ]]; then local content content="$(<"$out_file")" local rewritten rewritten="$(codex_rewrite_text "$content")" if [[ "$rewritten" != "$content" ]]; then printf '%s\n' "$rewritten" > "$out_file" fi fi fi fi done < <(find "$source_dir" -mindepth 1 -maxdepth 1 -print0) } verify_passthrough_resources() { local source_dir="$1" local output_dir="$2" local entry base local missing=() while IFS= read -r -d '' entry; do base="$(basename "$entry")" case "$base" in SKILL.md|prompt.md) continue ;; esac if [[ ! -e "$output_dir/$base" ]]; then missing+=("$base") fi done < <(find "$source_dir" -mindepth 1 -maxdepth 1 -print0) if [[ ${#missing[@]} -gt 0 ]]; then die "Passthrough parity check failed for '$source_dir'; missing in output: ${missing[*]}" fi } # Resolve a possibly-nonexistent absolute path to its physical form, collapsing # `..` and symlinks against the deepest existing ancestor. Used before any # destructive comparison so the guard cannot be fooled by an unresolved path. resolve_physical() { local target="$1" suffix="" while [[ ! -e "$target" ]]; do suffix="/$(basename "$target")$suffix" target="$(dirname "$target")" [[ "$target" == "/" ]] && break done if [[ -d "$target" ]]; then printf '%s%s\n' "$(cd "$target" && pwd -P)" "$suffix" else printf '%s/%s%s\n' "$(cd "$(dirname "$target")" && pwd -P)" "$(basename "$target")" "$suffix" fi } # within CHILD PARENT -> 0 if CHILD equals PARENT or is nested under PARENT. # Both arguments must already be canonical (symlink/.. resolved). within() { local child="$1" parent="$2" # Root is the ancestor of everything; the general glob below would build # the broken pattern "//*" for it. [[ "$parent" == "/" ]] && return 0 case "$child/" in "$parent"/*) return 0 ;; esac return 1 } # Refuse a clean-write target that would destroy the source or the repository. # The write stage rm -rf's output_dir; both paths are canonicalized (symlinks # and `..` resolved via resolve_physical / `pwd -P`) before comparison so the # guard cannot be bypassed by an alias. The containment test is BIDIRECTIONAL: # refuse when the output equals the source, contains it (ancestor), OR lives # inside it (descendant) — any of those puts source files under the rm -rf — and # refuse when the output is, or is an ancestor of, the repository root (an output # above the repo would take the whole tree with it). CV-1: a source-dir output # went 4 files -> 2 at exit 0. Fix by refusal, never by silently appending a # subdir. assert_safe_output_dir() { local output_dir="$1" source_dir="$2" local out_abs src_abs repo_abs out_abs="$(resolve_physical "$output_dir")" src_abs="$(cd "$source_dir" && pwd -P)" repo_abs="$(cd "$REPO_ROOT" && pwd -P)" if within "$out_abs" "$src_abs"; then die "refusing to clean-write '$out_abs': it is the source package, or lives inside it ('$src_abs')" fi if within "$src_abs" "$out_abs"; then die "refusing to clean-write '$out_abs': it contains the source package '$src_abs'" fi if within "$repo_abs" "$out_abs"; then die "refusing to clean-write '$out_abs': it is, or contains, the repository root '$repo_abs'" fi } write_output() { local output_dir="$1" local source_dir="$2" # Guard BEFORE the destructive clean-write below. assert_safe_output_dir "$output_dir" "$source_dir" # Clean-write: delete target dir before writing if [[ -d "$output_dir" ]]; then rm -rf "$output_dir" fi mkdir -p "$output_dir" printf '%s\n' "$CONVERTED_OUTPUT" > "$output_dir/$CONVERTED_FILENAME" echo "OK: $output_dir/$CONVERTED_FILENAME" # Write secondary output if present (e.g., codex prompt.md) if [[ -n "${CONVERTED_OUTPUT_2:-}" && -n "${CONVERTED_FILENAME_2:-}" ]]; then printf '%s\n' "$CONVERTED_OUTPUT_2" > "$output_dir/$CONVERTED_FILENAME_2" echo "OK: $output_dir/$CONVERTED_FILENAME_2" fi copy_passthrough_resources "$source_dir" "$output_dir" verify_passthrough_resources "$source_dir" "$output_dir" } # ─── Main ───────────────────────────────────────────────────────────── convert_one_skill() { local skill_dir="$1" local target="$2" local output_dir="$3" # Resolve skill_dir to absolute if relative if [[ "$skill_dir" != /* ]]; then skill_dir="$REPO_ROOT/$skill_dir" fi [[ -d "$skill_dir" ]] || die "Skill directory not found: $skill_dir" [[ -f "$skill_dir/SKILL.md" ]] || die "No SKILL.md in: $skill_dir" parse_bundle "$skill_dir" [[ -n "$BUNDLE_NAME" ]] || die "Failed to parse name from $skill_dir/SKILL.md" # Default output dir (ADR-0016 closed set: generated projection tier) if [[ -z "$output_dir" ]]; then output_dir="$REPO_ROOT/.agents/projections/converter/$target/$BUNDLE_NAME" elif [[ "$output_dir" != /* ]]; then output_dir="$REPO_ROOT/$output_dir" fi # Reset output variables CONVERTED_OUTPUT="" CONVERTED_FILENAME="" CONVERTED_OUTPUT_2="" CONVERTED_FILENAME_2="" run_convert "$target" write_output "$output_dir" "$skill_dir" } main() { parse_args "$@" local skill_dir_or_flag="$ARGS_SKILL_DIR_OR_FLAG" local target="$ARGS_TARGET" local output_dir="$ARGS_OUTPUT_DIR" load_skill_pattern if [[ "$skill_dir_or_flag" == "--all" ]]; then local skills_root="$REPO_ROOT/skills" local count=0 for d in "$skills_root"/*/; do [[ -f "$d/SKILL.md" ]] || continue local sname sname="$(basename "$d")" local out="$output_dir" if [[ -n "$out" ]]; then # Per-skill subdir under the provided output dir if [[ "$out" != /* ]]; then out="$REPO_ROOT/$out/$sname" else out="$out/$sname" fi fi convert_one_skill "$d" "$target" "$out" count=$((count + 1)) done echo "Converted $count skills to target '$target'" else convert_one_skill "$skill_dir_or_flag" "$target" "$output_dir" fi } main "$@" -
validate.sh 3.5 KB
#!/usr/bin/env bash # Behavioral self-test for the converter. Replaces the prior vacuous check # ("SKILL.md exists" only, which passed while the pipeline could delete its own # source) with real proofs (CV-2): # 1. a happy-path conversion writes the expected target + passthrough files; # 2. the destructive clean-write path is CLOSED in BOTH directions — an output # dir equal to, an ancestor of, a descendant of, or a symlink into the # source package is refused; and an output that is or contains the repo # root is refused (CV-1). Each refusal is proven to happen BEFORE any # deletion: the source content digest is unchanged AND the exit is nonzero. set -euo pipefail SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" CONVERT="$SKILL_DIR/scripts/converter/convert.sh" REPO_ROOT="$(cd "$SKILL_DIR/../.." && pwd -P)" FAIL=0 pass() { printf 'PASS: %s\n' "$1"; } fail() { printf 'FAIL: %s\n' "$1"; FAIL=$((FAIL + 1)); } # Content + structure digest of a directory tree (path-relative, order-stable). dir_digest() { ( cd "$1" && find . -type f -exec shasum -a 256 {} + | LC_ALL=C sort ) \ | shasum -a 256 | awk '{print $1}' } # assert_refused DESC OUTPUT — the conversion must exit nonzero AND leave the # source content digest unchanged (refusal before any deletion). assert_refused() { local desc="$1" out="$2" if bash "$CONVERT" "$FIX" codex "$out" >/dev/null 2>&1; then fail "$desc: expected refusal, got success" return fi if [[ "$(dir_digest "$FIX")" == "$SRC_DIGEST" ]]; then pass "$desc: refused before any deletion; source content intact" else fail "$desc: source content changed — refusal came too late" fi } if [[ -f "$SKILL_DIR/SKILL.md" ]]; then pass "SKILL.md exists"; else fail "SKILL.md exists"; fi if [[ -f "$CONVERT" ]]; then pass "convert.sh exists"; else fail "convert.sh exists"; fi # Disposable fixture, left in place on exit (no rm -rf): the guard under test # must never be handed a destructive cleanup to imitate. WORK="$(mktemp -d "${TMPDIR:-/tmp}/converter-selftest.XXXXXX")" FIX="$WORK/fixture-skill" mkdir -p "$FIX/references" "$FIX/scripts" printf -- '---\nname: fixture-skill\ndescription: converter self-test fixture\n---\n# Body\n' > "$FIX/SKILL.md" printf 'reference payload\n' > "$FIX/references/note.md" printf 'echo fixture\n' > "$FIX/scripts/tool.sh" SRC_DIGEST="$(dir_digest "$FIX")" # 1. Happy path: a distinct output dir converts, writes the target files, and # does not touch the source. out="$WORK/out" if bash "$CONVERT" "$FIX" codex "$out" >/dev/null 2>&1 \ && [[ -f "$out/SKILL.md" && -f "$out/prompt.md" && -f "$out/references/note.md" && -f "$out/scripts/tool.sh" ]] \ && [[ "$(dir_digest "$FIX")" == "$SRC_DIGEST" ]]; then pass "happy-path conversion writes target + passthrough files; source intact" else fail "happy-path conversion writes target + passthrough files; source intact" fi # 2. Bidirectional + repo containment refusals. assert_refused "output == source" "$FIX" assert_refused "output is an ancestor of source" "$WORK" assert_refused "output is a descendant of source" "$FIX/references" assert_refused "output is an ancestor of the repo root" "$(dirname "$REPO_ROOT")" # Symlink resolving into the source must be canonicalized and refused. ln -s "$FIX/references" "$WORK/link-into-src" assert_refused "output is a symlink into the source" "$WORK/link-into-src" echo "" if [[ "$FAIL" -eq 0 ]]; then echo "converter self-test: PASS" exit 0 fi echo "converter self-test: FAIL ($FAIL failed)" >&2 exit 1
-
-
audit.sh 17.3 KB
#!/usr/bin/env bash # audit.sh — two-pass skill audit (skill-builder deep audit mode; absorbed from /skill-auditor) # Pass 1 gates through heal.sh --check --strict; Pass 2 adds 8 NEW content-discipline checks. # Canonical SKILL.md template: skills/skill-builder/references/skill-template.md # # Usage: # audit.sh [--strict] [--json <path>] <skills/path> # # Exit codes: # 0 — PASS or WARN (success) # 1 — FAIL (or WARN under --strict) # 2 — usage error or missing target set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" HEAL_SH="$SCRIPT_DIR/heal.sh" SCORE_PY="$SCRIPT_DIR/score_agentops_skill.py" CRAFT_PY="$SCRIPT_DIR/craft_score.py" AUTHORING_PY="$SCRIPT_DIR/authoring_scan.py" PROFILE_TOOL="$REPO_ROOT/skills/skill-builder/scripts/conformance_profile.py" STRICT=0 JSON_OUT="" TARGET="" usage() { echo "Usage: $0 [--strict] [--json <path>] <skills/path>" >&2 exit 2 } while [[ $# -gt 0 ]]; do case "$1" in --strict) STRICT=1; shift ;; --json) JSON_OUT="${2:-}"; shift 2 ;; --help|-h) usage ;; --*) echo "Unknown flag: $1" >&2; usage ;; *) TARGET="$1"; shift ;; esac done [[ -n "$TARGET" ]] || usage [[ -d "$TARGET" ]] || { echo "audit.sh: target $TARGET is not a directory" >&2; exit 2; } SKILL_MD="$TARGET/SKILL.md" [[ -f "$SKILL_MD" ]] || { echo "audit.sh: no SKILL.md at $SKILL_MD" >&2; exit 2; } # Load profile identity, rule order/severities, and shared evaluations before # emitting any verdict. Missing or malformed configuration fails closed. TARGET_ABS="$(cd "$TARGET" && pwd)" CANONICAL_TARGET=0 case "$TARGET_ABS" in "$REPO_ROOT"/skills/*|"$REPO_ROOT"/skills-codex/*) CANONICAL_TARGET=1 ;; esac SELECTED_PROFILE="${SKILL_CONFORMANCE_PROFILE_ID:-}" if [[ -z "$SELECTED_PROFILE" && "$CANONICAL_TARGET" -eq 0 ]] \ && ! grep -q '^skill_api_version:' "$SKILL_MD"; then SELECTED_PROFILE="external-observation" fi profile_args=(--repo-root "$REPO_ROOT" --audit-tsv "$SKILL_MD") if [[ -n "$SELECTED_PROFILE" ]]; then profile_args+=(--profile-id "$SELECTED_PROFILE") fi if [[ ! -f "$PROFILE_TOOL" ]]; then echo "profile configuration missing: $PROFILE_TOOL" >&2 exit 2 fi if ! PROFILE_DATA="$(python3 "$PROFILE_TOOL" "${profile_args[@]}")"; then exit 2 fi PROFILE_ID="" KERNEL_MAX_LINES="" PROFILE_LINE_COUNT="" TRIGGER_FORMS="" OUTPUT_COMPLETE="false" RULE_IDS=() declare -A CHECK_SEVERITY=() declare -A PROFILE_RULE_FORMS=() while IFS=$'\t' read -r kind value extra forms; do case "$kind" in profile_id) PROFILE_ID="$value" ;; kernel_max_lines) KERNEL_MAX_LINES="$value" ;; line_count) PROFILE_LINE_COUNT="$value" ;; trigger_forms) TRIGGER_FORMS="$value" ;; output_complete) OUTPUT_COMPLETE="$value" ;; rule) RULE_IDS+=("$value") CHECK_SEVERITY[$value]="$extra" PROFILE_RULE_FORMS[$value]="$forms" ;; esac done <<<"$PROFILE_DATA" if [[ -z "$PROFILE_ID" || -z "$KERNEL_MAX_LINES" || ${#RULE_IDS[@]} -eq 0 ]]; then echo "profile configuration error: incomplete evaluated profile data" >&2 exit 2 fi # --- Pass 1: heal structural ---------------------------------------------- PASS1_OUT="" PASS1_FINDINGS_JSON="[]" PASS1_AUTOFIXABLE=0 PASS1_STATUS="pass" PASS1_EXIT_CODE=0 PASS1_FINDING_COUNT=0 if [[ -x "$HEAL_SH" ]]; then if PASS1_OUT="$(bash "$HEAL_SH" --check --strict "$TARGET" 2>&1)"; then PASS1_STATUS="pass" PASS1_EXIT_CODE=0 else PASS1_EXIT_CODE=$? PASS1_STATUS="fail" fi # Parse [CODE] path: msg lines into JSON. Use Python here because BSD awk # lacks gawk's match(..., array) extension. PASS1_FINDINGS_JSON=$(PASS1_OUT="$PASS1_OUT" python3 - <<'PY' import json import os import re findings = [] pattern = re.compile(r"^\[([A-Z_]+)\] ([^:]+): (.*)$") for line in os.environ.get("PASS1_OUT", "").splitlines(): match = pattern.match(line) if match: code, path, msg = match.groups() findings.append({"code": code, "path": path, "msg": msg}) print(json.dumps(findings)) PY ) # Count the complete heal.sh auto-fix allowlist. PASS1_AUTOFIXABLE=$(echo "$PASS1_OUT" | grep -cE '^\[(MISSING_NAME|MISSING_DESC|NAME_MISMATCH|UNLINKED_REF|EMPTY_DIR|MISSING_API_VERSION)\]' || true) else PASS1_STATUS="fail" PASS1_EXIT_CODE=2 PASS1_OUT="heal delegate missing or not executable: $HEAL_SH" PASS1_FINDINGS_JSON='[{"code":"HEAL_SKILL_MISSING","path":"skills/skill-builder/scripts/heal.sh","msg":"heal delegate missing or not executable"}]' fi PASS1_FINDING_COUNT=$(PASS1_FINDINGS_JSON="$PASS1_FINDINGS_JSON" python3 - <<'PY' import json import os try: print(len(json.loads(os.environ.get("PASS1_FINDINGS_JSON", "[]")))) except Exception: print(0) PY ) # --- Pass 2: 8 NEW checks ------------------------------------------------ # Check 1: description-has-triggers (WARN on miss; run_check registers severity) check_description_has_triggers() { profile_rule_has_form description-has-triggers } profile_rule_has_form() { local rule_id="$1" accepted form accepted=",${PROFILE_RULE_FORMS[$rule_id]}," IFS=',' read -r -a found_forms <<<"$TRIGGER_FORMS" for form in "${found_forms[@]}"; do [[ -n "$form" && "$accepted" == *",$form,"* ]] && return 0 done return 1 } # Check 2: constraints-frontloaded (WARN on miss) check_constraints_frontloaded() { local skill_md="$1" if (( PROFILE_LINE_COUNT <= 100 )); then return 0; fi awk ' BEGIN{n=0; i=0; found=0} /^---$/{n++; next} n==2 { i++ if (i > 80) { exit 1 } if (/^## .*[Cc]onstraints/ || /^## .*⚠️/) { found=1; exit 0 } } END{ exit (found ? 0 : 1) } ' "$skill_md" } # Check 3: rationale-present (WARN on miss) check_rationale_present() { local skill_md="$1" awk ' function flush_bullet() { if (!bullet_open) return bullets++ if (bullet_text ~ /[Ww][Hh][Yy]|[Bb]ecause|[Tt]his matters|[Tt]o prevent|[Rr]ationale:|[Mm]otivation:/) with_why++ bullet_open=0 bullet_text="" } BEGIN{in_constraints=0; bullets=0; with_why=0; bullet_open=0} /^## .*([Cc]onstraints|⚠️)/{in_constraints=1; next} in_constraints && /^## /{flush_bullet(); exit} in_constraints && /^[ ]*[-*] /{ flush_bullet() bullet_open=1 bullet_text=$0 next } in_constraints && bullet_open{bullet_text=bullet_text " " $0} END{ flush_bullet() if (bullets == 0) exit 0 exit (with_why * 2 >= bullets ? 0 : 1) } ' "$skill_md" } # Check 4: verification-checkpoints (WARN on miss, conditional) check_verification_checkpoints() { local skill_md="$1" local phases checkpoints phases=$(awk '/^## (Workflow|Methodology|Process|Execution)/{in_w=1; next} in_w && /^## /{exit} in_w && /^### /{n++} END{print n+0}' "$skill_md") if (( phases < 2 )); then return 0; fi checkpoints=$(grep -cE '\*\*Checkpoint:|confirm before|Wait for|verify before' "$skill_md" 2>/dev/null || echo 0) (( checkpoints >= 1 )) } # Check 5: output-spec-explicit (FAIL on miss) check_output_spec_explicit() { [[ "$OUTPUT_COMPLETE" == "true" ]] } # Check 6: quality-rubric (WARN on miss) check_quality_rubric() { local skill_md="$1" if (( PROFILE_LINE_COUNT <= 100 )); then return 0; fi awk ' BEGIN{in_q=0; bullets=0} /^## (Quality|Checks|Checklist|Rubric|Best Practices|Acceptance)/{in_q=1; next} in_q && /^## /{exit} in_q && /^[ ]*[-*] /{bullets++} END{exit (bullets >= 3 ? 0 : 1)} ' "$skill_md" } # Check 7: references-modularization (WARN on miss, conditional) check_references_modularization() { (( PROFILE_LINE_COUNT <= KERNEL_MAX_LINES )) } # Check 8: trigger-clarity (WARN on miss; run_check registers severity) check_trigger_clarity() { profile_rule_has_form trigger-clarity } # --- Run all 8 checks ---------------------------------------------------- declare -A CHECK_STATUS=() declare -A CHECK_EVIDENCE=() run_check() { local id="$1" local fn="$2" local severity="${CHECK_SEVERITY[$id]}" if "$fn" "$SKILL_MD"; then CHECK_STATUS[$id]="pass" CHECK_EVIDENCE[$id]="check passed" else CHECK_STATUS[$id]="${severity,,}" CHECK_EVIDENCE[$id]="check failed" fi } run_check description-has-triggers check_description_has_triggers run_check constraints-frontloaded check_constraints_frontloaded run_check rationale-present check_rationale_present run_check verification-checkpoints check_verification_checkpoints run_check output-spec-explicit check_output_spec_explicit run_check quality-rubric check_quality_rubric run_check references-modularization check_references_modularization run_check trigger-clarity check_trigger_clarity # --- Advisory density report --------------------------------------------- # This is deliberately not part of the PASS/WARN/FAIL verdict. Packet-boundary # enforcement belongs to the execution-packet schema; this block helps reviewers # find low-signal skill prose before fresh-context dispatch. declare -A DENSITY_PRESENT=() declare -A DENSITY_EVIDENCE=() check_density_field() { local id="$1" local pattern="$2" if grep -Eiq -- "$pattern" "$SKILL_MD"; then DENSITY_PRESENT[$id]="true" DENSITY_EVIDENCE[$id]="matched advisory pattern" else DENSITY_PRESENT[$id]="false" DENSITY_EVIDENCE[$id]="missing advisory pattern" fi } check_density_field intent 'intent|goal|behavior|capability' check_density_field boundary 'boundary|bounded context|write scope|non-goal|non-goals' check_density_field evidence 'evidence|test|tests|verdict|validation|acceptance' check_density_field decision 'decision|rationale|why|because|chosen' check_density_field constraint 'constraint|constraints|guardrail|guardrails|limit|limits|scope' check_density_field next_action 'next_action|next action|next steps|completion marker|report completion' density_present_count=0 for id in intent boundary evidence decision constraint next_action; do if [[ "${DENSITY_PRESENT[$id]}" == "true" ]]; then density_present_count=$((density_present_count + 1)) fi done if (( density_present_count == 6 )); then DENSITY_STATUS="pass" else DENSITY_STATUS="warn" fi # --- Pass 3: static package-readiness scoring (advisory) ----------------- # Folds the 10-category Skill Quality Rubric (docs/reference/skill-quality-rubric.md) # into the report via score_agentops_skill.py --audit-block. Each category gets a # deterministic 0-3 score plus an explainable reason; total is 0-30 with a C/B/A/S # readiness band. Advisory-only: it never changes the PASS/WARN/FAIL verdict and # explicitly evaluates neither the safety gate nor behavioral effectiveness. # Reason: a low score on a structurally clean skill is a triage signal, while a # high score still cannot prove that the skill is safe or improves outcomes. RUBRIC_JSON="null" RUBRIC_SUMMARY="" RUBRIC_SCORE="n/a" RUBRIC_MAX="n/a" RUBRIC_RATING="?" if [[ -f "$SCORE_PY" ]] && command -v python3 >/dev/null 2>&1; then if rubric_out="$(python3 "$SCORE_PY" "$TARGET" --audit-block 2>/dev/null)"; then RUBRIC_JSON="$rubric_out" RUBRIC_SCORE="$(printf '%s' "$rubric_out" | awk -F': ' '/"total_score"/{gsub(/[, ]/,"",$2); print $2; exit}')" RUBRIC_MAX="$(printf '%s' "$rubric_out" | awk -F': ' '/"max_score"/{gsub(/[, ]/,"",$2); print $2; exit}')" RUBRIC_RATING="$(printf '%s' "$rubric_out" | awk -F'"' '/"rating"/{print $4; exit}')" RUBRIC_SUMMARY=" Static readiness: ${RUBRIC_SCORE}/${RUBRIC_MAX} (${RUBRIC_RATING}) [advisory; safety/effectiveness not evaluated]." fi fi # --- Pass 4: craft instrumentation (advisory) ----------------------------- # 12-element craft score, provenance resolution, and loop-safety findings via # craft_score.py. Advisory-only by design: it never changes the PASS/WARN/FAIL # verdict or the exit code — presence of craft elements is cheaply detectable, # but craft quality stays the fresh validator's judgment. Fail-open to null # like the Pass 3 rubric. CRAFT_JSON="null" CRAFT_LINES="" if [[ -f "$CRAFT_PY" ]] && command -v python3 >/dev/null 2>&1; then if craft_out="$(python3 "$CRAFT_PY" "$TARGET" --audit-block --repo-root "$REPO_ROOT" 2>/dev/null)"; then CRAFT_JSON="$craft_out" CRAFT_LINES="$(CRAFT_OUT="$craft_out" python3 - 2>/dev/null <<'PY' || true import json import os report = json.loads(os.environ["CRAFT_OUT"]) lines = [f"Pass 4 craft (advisory): {report['summary']}"] prov = report["provenance"] lines.append(f"Provenance (advisory): {prov['resolved']}/{prov['citations']} citations resolve") for dead in prov["dead"]: lines.append(f" dead citation: {dead['citation']} ({dead['kind']})") loop = report["loop_safety"]["findings"] lines.append(f"Loop-safety (advisory): {len(loop)} finding(s)") for finding in loop: lines.append(f" {finding['type']} in section '{finding['section']}'") print("\n".join(lines)) PY )" fi fi # --- Pass 5: authoring prose-quality scan (advisory) ---------------------- # Advisory suspects for the failure modes named in # references/authoring-doctrine.md (noop-phrase, negation-without-positive, # step-missing-done-condition). Advisory-only: the doctrine's no-op test is # model-relative and prohibitions are sometimes correct guardrails, so these # findings never change the PASS/WARN/FAIL verdict or exit code. Fail-open to # null like the Pass 3 rubric and Pass 4 craft blocks. AUTHORING_JSON="null" AUTHORING_LINES="" if [[ -f "$AUTHORING_PY" ]] && command -v python3 >/dev/null 2>&1; then if authoring_out="$(python3 "$AUTHORING_PY" "$TARGET" --audit-block 2>/dev/null)"; then AUTHORING_JSON="$authoring_out" AUTHORING_LINES="$(AUTHORING_OUT="$authoring_out" python3 - 2>/dev/null <<'PY' || true import json import os report = json.loads(os.environ["AUTHORING_OUT"]) lines = [f"Pass 5 authoring (advisory): {report['summary']}"] for finding in report["findings"]: lines.append(f" {finding['id']} line {finding['line']}: {finding['evidence']}") print("\n".join(lines)) PY )" fi fi # --- Aggregate verdict --------------------------------------------------- fails=0 warns=0 for id in "${RULE_IDS[@]}"; do case "${CHECK_STATUS[$id]}" in fail) fails=$((fails+1)) ;; warn) warns=$((warns+1)) ;; esac done if [[ "$PASS1_STATUS" == "fail" && "$CANONICAL_TARGET" -eq 1 ]]; then VERDICT="FAIL" elif (( fails > 0 )); then VERDICT="FAIL" elif (( warns > 0 )); then VERDICT="WARN" else VERDICT="PASS" fi # --- Emit report --------------------------------------------------------- emit_json() { printf '{\n' printf ' "target": "%s",\n' "$TARGET" printf ' "profile_id": "%s",\n' "$PROFILE_ID" printf ' "verdict": "%s",\n' "$VERDICT" printf ' "pass1": {\n' printf ' "status": "%s",\n' "$PASS1_STATUS" printf ' "exit_code": %s,\n' "$PASS1_EXIT_CODE" printf ' "strict": true,\n' printf ' "findings": %s,\n' "$PASS1_FINDINGS_JSON" printf ' "autofixable": %s\n' "$PASS1_AUTOFIXABLE" printf ' },\n' printf ' "pass2": {\n' printf ' "checks": [\n' local first=1 for id in "${RULE_IDS[@]}"; do if (( ! first )); then printf ',\n'; fi first=0 printf ' {"id":"%s","status":"%s","severity":"%s","evidence":"%s"' \ "$id" "${CHECK_STATUS[$id]}" "${CHECK_SEVERITY[$id]}" "${CHECK_EVIDENCE[$id]}" if [[ "$id" == "description-has-triggers" || "$id" == "trigger-clarity" ]]; then forms_json="$(python3 - "$TRIGGER_FORMS" <<'PY' import json import sys print(json.dumps([item for item in sys.argv[1].split(",") if item])) PY )" printf ',"forms":%s' "$forms_json" fi printf '}' done printf '\n ]\n' printf ' },\n' printf ' "density": {\n' printf ' "status": "%s",\n' "$DENSITY_STATUS" printf ' "advisory": true,\n' printf ' "fields": [\n' first=1 for id in intent boundary evidence decision constraint next_action; do if (( ! first )); then printf ',\n'; fi first=0 printf ' {"id":"%s","present":%s,"evidence":"%s"}' "$id" "${DENSITY_PRESENT[$id]}" "${DENSITY_EVIDENCE[$id]}" done printf '\n ],\n' printf ' "summary": "%d/6 density signals present; advisory-only and not execution-packet enforcement."\n' "$density_present_count" printf ' },\n' printf ' "rubric": %s,\n' "$RUBRIC_JSON" printf ' "craft": %s,\n' "$CRAFT_JSON" printf ' "authoring": %s,\n' "$AUTHORING_JSON" printf ' "summary": "Pass1: %s via heal --strict (exit %d, %d findings, %d autofixable). Pass2: %d fails, %d warns.%s Verdict: %s."\n' \ "$PASS1_STATUS" "$PASS1_EXIT_CODE" "$PASS1_FINDING_COUNT" "$PASS1_AUTOFIXABLE" "$fails" "$warns" "$RUBRIC_SUMMARY" "$VERDICT" printf '}\n' } if [[ -n "$JSON_OUT" ]]; then emit_json > "$JSON_OUT" fi # Always print human-readable summary to stderr { echo "=== Skill Audit: $TARGET ===" echo "Profile: $PROFILE_ID" echo "Pass 1 (heal --strict): $PASS1_STATUS (exit $PASS1_EXIT_CODE), $PASS1_FINDING_COUNT findings ($PASS1_AUTOFIXABLE autofixable)" echo "Pass 2 (8 NEW checks):" for id in "${RULE_IDS[@]}"; do printf " [%-4s] %s\n" "${CHECK_STATUS[$id]}" "$id" done echo "Density advisory: $density_present_count/6 fields present ($DENSITY_STATUS)" echo "Pass 3 static readiness (advisory): ${RUBRIC_SCORE}/${RUBRIC_MAX} (${RUBRIC_RATING}); safety/effectiveness not evaluated" if [[ -n "$CRAFT_LINES" ]]; then echo "$CRAFT_LINES" fi if [[ -n "$AUTHORING_LINES" ]]; then echo "$AUTHORING_LINES" fi echo "VERDICT: $VERDICT" } >&2 # Always print JSON to stdout (unless --json file was supplied) if [[ -z "$JSON_OUT" ]]; then emit_json fi # --- Exit code ----------------------------------------------------------- case "$VERDICT" in PASS) exit 0 ;; WARN) [[ "$STRICT" -eq 1 ]] && exit 1 || exit 0 ;; FAIL) exit 1 ;; esac -
authoring_scan.py 7.1 KB
#!/usr/bin/env python3 """authoring_scan.py — advisory prose-quality findings for one skill package. Emits the deep audit's `authoring` block: mechanical suspects for the three detectable failure modes named in references/authoring-doctrine.md. noop-phrase — phrasing the model already obeys by default negation-without-positive — a prohibition with no positive counterpart in the same bullet/paragraph step-missing-done-condition — a workflow subphase with no checkable done-condition phrasing Advisory-only by design: findings never gate a verdict or exit code. The doctrine explains why (the no-op test is model-relative; prohibitions are sometimes correct guardrails). Output is JSON on stdout; exit 0 on any successful scan, 2 on usage/read errors. """ from __future__ import annotations import argparse import json import re import sys from pathlib import Path NOOP_PHRASES = [ r"be thorough(?:ly)?\b", r"be careful\b", r"make sure to\b", r"\bcarefully\b", r"do your best\b", r"as appropriate\b", r"remember to\b", r"be diligent\b", ] NEGATION_START = re.compile(r"^\s*(?:do not|don't|never|avoid)\b", re.IGNORECASE) NEGATION_TOKEN = re.compile(r"\b(?:do not|don't|never|avoid)\b", re.IGNORECASE) POSITIVE_MARKER = re.compile( r"\b(?:instead|corrective:|prefer|rather,)\s*", re.IGNORECASE ) DONE_CONDITION = re.compile( r"(?:done when|checkpoint:|stop after|complete when|finished when|" r"until\b[^\n]*exit 0|exit 0|verify before|confirm before|wait for|" r"at most \d+)", re.IGNORECASE, ) WORKFLOW_HEADING = re.compile( r"^##\s+.*(?:workflow|process|methodology|execution)", re.IGNORECASE ) def strip_non_prose(text: str) -> str: """Blank out frontmatter, fenced code blocks, and HTML comments while preserving line numbering.""" lines = text.splitlines() out: list[str] = [] in_front = False in_fence = False for i, line in enumerate(lines): stripped = line.strip() if i == 0 and stripped == "---": in_front = True out.append("") continue if in_front: out.append("") if stripped == "---": in_front = False continue if stripped.startswith("```"): in_fence = not in_fence out.append("") continue if in_fence: out.append("") continue out.append(re.sub(r"<!--.*?-->", "", line)) body = "\n".join(out) # multi-line HTML comments return re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"), body, flags=re.DOTALL) def find_noop_phrases(prose: str) -> list[dict]: findings = [] for lineno, line in enumerate(prose.splitlines(), start=1): for pat in NOOP_PHRASES: if re.search(pat, line, re.IGNORECASE): findings.append( { "id": "noop-phrase", "line": lineno, "evidence": line.strip()[:160], } ) break return findings def units_with_lines(prose: str): """Yield (start_line, unit_text) for paragraph/bullet units.""" lines = prose.splitlines() unit: list[str] = [] start = 1 for i, line in enumerate(lines, start=1): is_bullet = bool(re.match(r"^\s*[-*] ", line)) if not line.strip(): if unit: yield start, "\n".join(unit) unit = [] continue if is_bullet and unit: yield start, "\n".join(unit) unit = [] if not unit: start = i unit.append(line) if unit: yield start, "\n".join(unit) def find_negation_without_positive(prose: str) -> list[dict]: findings = [] for start, unit in units_with_lines(prose): if unit.lstrip().startswith("#"): continue if not NEGATION_TOKEN.search(unit): continue if POSITIVE_MARKER.search(unit): continue clauses = [c.strip() for c in re.split(r"[.;]", unit) if c.strip()] # A clause free of negation tokens is treated as the positive # counterpart; the unit is flagged only when no such clause exists. if any(not NEGATION_TOKEN.search(c) for c in clauses): continue findings.append( { "id": "negation-without-positive", "line": start, "evidence": unit.strip().replace("\n", " ")[:160], } ) return findings def find_steps_missing_done_condition(prose: str) -> list[dict]: findings = [] lines = prose.splitlines() in_workflow = False sub_name = None sub_start = 0 sub_buf: list[str] = [] def flush(): if sub_name is None: return text = "\n".join(sub_buf) if not DONE_CONDITION.search(text): findings.append( { "id": "step-missing-done-condition", "line": sub_start, "evidence": f"subphase '{sub_name}' has no checkable done condition", } ) for i, line in enumerate(lines, start=1): if line.startswith("## "): flush() sub_name = None sub_buf = [] in_workflow = bool(WORKFLOW_HEADING.match(line)) continue if in_workflow and line.startswith("### "): flush() sub_name = line[4:].strip() sub_start = i sub_buf = [] continue if sub_name is not None: sub_buf.append(line) flush() return findings def scan(skill_md: Path) -> dict: prose = strip_non_prose(skill_md.read_text(encoding="utf-8")) findings = ( find_noop_phrases(prose) + find_negation_without_positive(prose) + find_steps_missing_done_condition(prose) ) counts = { "noop-phrase": 0, "negation-without-positive": 0, "step-missing-done-condition": 0, } for f in findings: counts[f["id"]] += 1 return { "advisory": True, "findings": findings, "counts": counts, "summary": "authoring: %d advisory finding(s) (%s)" % ( len(findings), ", ".join(f"{k}={v}" for k, v in counts.items()), ), } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("target", help="skill package directory containing SKILL.md") parser.add_argument("--audit-block", action="store_true", help="emit the audit report block (default output shape)") args = parser.parse_args() skill_md = Path(args.target) / "SKILL.md" if not skill_md.is_file(): print(f"authoring_scan: no SKILL.md at {skill_md}", file=sys.stderr) return 2 try: report = scan(skill_md) except OSError as exc: print(f"authoring_scan: {exc}", file=sys.stderr) return 2 json.dump(report, sys.stdout, indent=2) print() return 0 if __name__ == "__main__": sys.exit(main()) -
build.sh 1.4 KB
#!/usr/bin/env bash # Create, structurally check, and project one skill exactly once. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="${SKILL_BUILDER_REPO_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}" usage() { cat >&2 <<EOF usage: build.sh from-scratch <slug> build.sh from-template <slug> --like <existing-slug> build.sh absorb-external <slug> --from <path> EOF exit 2 } [[ $# -ge 2 ]] || usage mode="$1" slug="$2" shift 2 case "$mode" in from-scratch) init_mode=--scratch ;; from-template) init_mode=--template ;; absorb-external) init_mode=--external ;; *) usage ;; esac bash "$SCRIPT_DIR/init.sh" "$init_mode" "$slug" "$@" report="$REPO_ROOT/.agents/scratch/skill-builder/${slug}-build.json" if ! HEAL_REPO_ROOT="$REPO_ROOT" bash "$REPO_ROOT/skills/skill-builder/scripts/heal.sh" \ --check --strict "$REPO_ROOT/skills/$slug"; then echo "skill-builder: structural check failed" >&2 exit 1 fi python3 "$REPO_ROOT/scripts/generate-skill-mesh.py" bash "$REPO_ROOT/scripts/codex-sync.sh" --only "$slug" bash "$REPO_ROOT/scripts/regen-codex-hashes.sh" --only "$slug" python3 - "$report" <<'PY' import json from pathlib import Path import sys path = Path(sys.argv[1]) payload = json.loads(path.read_text(encoding="utf-8")) payload["structure_check_pass"] = True path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") PY echo "skill-builder: created and projected $slug" -
conformance_profile.py 18 KB
#!/usr/bin/env python3 """Load and evaluate the canonical AgentOps skill-conformance profile.""" from __future__ import annotations import argparse import re import sys from pathlib import Path from typing import Any import yaml PROFILE_RELATIVE_PATH = Path( "skills/skill-builder/references/skill-conformance-profiles.yaml" ) KNOWN_SEVERITIES = {"WARN", "FAIL"} KNOWN_TRIGGER_FORMS = {"inline-marker", "block-marker", "metadata-list"} REQUIRED_RULE_IDS = ( "description-has-triggers", "constraints-frontloaded", "rationale-present", "verification-checkpoints", "output-spec-explicit", "quality-rubric", "references-modularization", "trigger-clarity", ) KNOWN_EXTERNAL_CONTENT_POLICIES = {"observe-structure-only"} REQUIRED_PROHIBITED_COPY_CATEGORIES = { "prose", "prompts", "scripts", "examples", "names", } REQUIRED_PROTECTED_FRONTMATTER_FIELDS = {"description"} class ProfileError(ValueError): """Raised when the conformance profile is missing or malformed.""" def _mapping(value: Any, label: str) -> dict[str, Any]: if not isinstance(value, dict): raise ProfileError(f"profile configuration error: {label} must be a mapping") return value def _string_list(value: Any, label: str) -> list[str]: if not isinstance(value, list) or not value or not all(isinstance(v, str) for v in value): raise ProfileError( f"profile configuration error: {label} must be a non-empty string list" ) return value def _positive_int(value: Any, label: str) -> int: if not isinstance(value, int) or value < 1: raise ProfileError(f"profile configuration error: {label} must be positive") return value def load_profile(repo_root: Path, profile_id: str | None = None) -> dict[str, Any]: """Load and fully validate one profile from the authoritative YAML file.""" path = repo_root / PROFILE_RELATIVE_PATH if not path.is_file(): raise ProfileError(f"profile configuration missing: {path}") try: document = yaml.safe_load(path.read_text(encoding="utf-8")) except (OSError, yaml.YAMLError) as exc: raise ProfileError(f"profile configuration error in {path}: {exc}") from exc document = _mapping(document, "document") profiles = _mapping(document.get("profiles"), "profiles") selected = profile_id or document.get("default_profile") if not isinstance(selected, str) or not selected: raise ProfileError("profile configuration error: default_profile must be a string") if selected not in profiles: raise ProfileError(f"profile configuration error: unknown profile {selected!r}") profile = _mapping(profiles[selected], f"profiles.{selected}") if profile.get("id") != selected: raise ProfileError( f"profile configuration error: profile id must equal selected id {selected!r}" ) limit = profile.get("kernel_max_lines") if not isinstance(limit, int) or limit < 1: raise ProfileError("profile configuration error: kernel_max_lines must be positive") trigger = _mapping(profile.get("trigger_forms"), "trigger_forms") accepted = _string_list(trigger.get("accepted"), "trigger_forms.accepted") unknown_forms = sorted(set(accepted) - KNOWN_TRIGGER_FORMS) if unknown_forms: raise ProfileError( f"profile configuration error: unknown trigger form(s): {', '.join(unknown_forms)}" ) _string_list(trigger.get("description_markers"), "trigger_forms.description_markers") minimum = trigger.get("metadata_list_min_items") if not isinstance(minimum, int) or minimum < 1: raise ProfileError( "profile configuration error: metadata_list_min_items must be positive" ) output = _mapping(profile.get("output_contract"), "output_contract") _string_list(output.get("section_headings"), "output_contract.section_headings") components = _mapping( output.get("required_components"), "output_contract.required_components" ) if not components: raise ProfileError( "profile configuration error: output_contract.required_components is empty" ) for component_id, component in components.items(): if not isinstance(component_id, str): raise ProfileError("profile configuration error: component id must be a string") component = _mapping(component, f"output component {component_id}") _string_list(component.get("markers"), f"output component {component_id}.markers") clean_room = _mapping(profile.get("clean_room"), "clean_room") if clean_room.get("enabled") is not True: raise ProfileError("profile configuration error: clean_room.enabled must be true") policy = clean_room.get("external_content_policy") if policy not in KNOWN_EXTERNAL_CONTENT_POLICIES: raise ProfileError( "profile configuration error: clean_room.external_content_policy " f"must be one of {sorted(KNOWN_EXTERNAL_CONTENT_POLICIES)}, got {policy!r}" ) categories = _string_list( clean_room.get("prohibited_copy_categories"), "clean_room.prohibited_copy_categories", ) if len(categories) != len(set(categories)): raise ProfileError( "profile configuration error: clean_room.prohibited_copy_categories " "contains duplicates" ) if set(categories) != REQUIRED_PROHIBITED_COPY_CATEGORIES: missing = sorted(REQUIRED_PROHIBITED_COPY_CATEGORIES - set(categories)) unknown = sorted(set(categories) - REQUIRED_PROHIBITED_COPY_CATEGORIES) raise ProfileError( "profile configuration error: clean_room.prohibited_copy_categories " f"must name the exact known categories (missing={missing}, unknown={unknown})" ) copy_detection = _mapping(clean_room.get("copy_detection"), "clean_room.copy_detection") _positive_int( copy_detection.get("minimum_fragment_characters"), "clean_room.copy_detection.minimum_fragment_characters", ) _positive_int( copy_detection.get("minimum_name_characters"), "clean_room.copy_detection.minimum_name_characters", ) protected_fields = _string_list( copy_detection.get("protected_frontmatter_fields"), "clean_room.copy_detection.protected_frontmatter_fields", ) if len(protected_fields) != len(set(protected_fields)): raise ProfileError( "profile configuration error: " "clean_room.copy_detection.protected_frontmatter_fields contains duplicates" ) if set(protected_fields) != REQUIRED_PROTECTED_FRONTMATTER_FIELDS: missing = sorted(REQUIRED_PROTECTED_FRONTMATTER_FIELDS - set(protected_fields)) unknown = sorted(set(protected_fields) - REQUIRED_PROTECTED_FRONTMATTER_FIELDS) raise ProfileError( "profile configuration error: " "clean_room.copy_detection.protected_frontmatter_fields must name the " f"exact known fields (missing={missing}, unknown={unknown})" ) _string_list( copy_detection.get("ignored_exact_lines"), "clean_room.copy_detection.ignored_exact_lines", ) _string_list( copy_detection.get("ignored_line_prefixes"), "clean_room.copy_detection.ignored_line_prefixes", ) rule_order = _string_list(profile.get("rule_order"), "rule_order") if len(rule_order) != len(set(rule_order)): raise ProfileError("profile configuration error: rule_order contains duplicates") rules = _mapping(profile.get("rules"), "rules") if set(rule_order) != set(rules): missing = sorted(set(rule_order) - set(rules)) extra = sorted(set(rules) - set(rule_order)) raise ProfileError( "profile configuration error: rule_order/rules mismatch " f"(missing={missing}, extra={extra})" ) known_rules = set(REQUIRED_RULE_IDS) actual_rules = set(rules) if actual_rules != known_rules: missing = sorted(known_rules - actual_rules) unknown = sorted(actual_rules - known_rules) raise ProfileError( "profile configuration error: profile must declare the exact known rule IDs " f"(missing={missing}, unknown={unknown})" ) if rule_order != list(REQUIRED_RULE_IDS): raise ProfileError( "profile configuration error: rule_order must use the canonical known rule " f"order {list(REQUIRED_RULE_IDS)}" ) for rule_id in rule_order: rule = _mapping(rules[rule_id], f"rules.{rule_id}") severity = rule.get("severity") if severity not in KNOWN_SEVERITIES: raise ProfileError( f"profile configuration error: rule {rule_id} has unknown severity {severity!r}" ) if "accepted_forms" in rule: forms = _string_list( rule["accepted_forms"], f"rules.{rule_id}.accepted_forms" ) unknown = sorted(set(forms) - set(accepted)) if unknown: raise ProfileError( f"profile configuration error: rule {rule_id} names unknown forms {unknown}" ) return profile def split_frontmatter(text: str) -> tuple[str, str]: """Return raw YAML frontmatter and Markdown body.""" if not text.startswith("---\n"): return "", text parts = text.split("\n---", 1) if len(parts) != 2: return "", text return parts[0][4:], parts[1].lstrip("-\n") def _frontmatter_mapping(text: str) -> tuple[str, dict[str, Any]]: raw, _ = split_frontmatter(text) try: payload = yaml.safe_load(raw) if raw else {} except yaml.YAMLError as exc: raise ProfileError(f"skill frontmatter configuration error: {exc}") from exc return raw, _mapping(payload, "skill frontmatter") def trigger_forms(text: str, profile: dict[str, Any]) -> list[str]: """Return accepted trigger form IDs found only in frontmatter semantics.""" raw, frontmatter = _frontmatter_mapping(text) trigger = profile["trigger_forms"] markers = trigger["description_markers"] description = frontmatter.get("description", "") description = description if isinstance(description, str) else "" has_marker = any(marker.casefold() in description.casefold() for marker in markers) scalar = re.search(r"^description:\s*([|>])", raw, re.MULTILINE) found: set[str] = set() if has_marker: found.add("block-marker" if scalar else "inline-marker") metadata = frontmatter.get("metadata") trigger_list = metadata.get("triggers") if isinstance(metadata, dict) else None if isinstance(trigger_list, list) and len(trigger_list) >= trigger["metadata_list_min_items"]: found.add("metadata-list") return [form for form in trigger["accepted"] if form in found] def output_component_results(text: str, profile: dict[str, Any]) -> dict[str, bool]: """Evaluate every required executable-handoff component in one output section.""" _, body = split_frontmatter(text) headings = {heading.casefold() for heading in profile["output_contract"]["section_headings"]} section_lines: list[str] = [] capturing = False for line in body.splitlines(): heading = re.match(r"^##\s+(.+?)\s*$", line) if heading: normalized = heading.group(1).strip().casefold() if capturing: break capturing = normalized in headings continue if capturing: section_lines.append(line) section = "\n".join(section_lines).casefold() results: dict[str, bool] = {} for component_id, component in profile["output_contract"]["required_components"].items(): results[component_id] = any( marker.casefold() in section for marker in component["markers"] ) return results def evaluation(skill_md: Path, profile: dict[str, Any]) -> dict[str, Any]: """Evaluate shared trigger, boundary, and output semantics for one skill.""" try: text = skill_md.read_text(encoding="utf-8") except OSError as exc: raise ProfileError(f"skill read error: {skill_md}: {exc}") from exc _, frontmatter = _frontmatter_mapping(text) forms = trigger_forms(text, profile) components = output_component_results(text, profile) declared_output = frontmatter.get("output_contract") has_declared_output = ( isinstance(declared_output, str) and bool(declared_output.strip()) ) or (isinstance(declared_output, (dict, list)) and bool(declared_output)) return { "profile_id": profile["id"], "kernel_max_lines": profile["kernel_max_lines"], "line_count": len(text.splitlines()), "trigger_forms": forms, "output_components": components, "output_complete": has_declared_output or all(components.values()), } def clean_room_copies( external_source: Path, generated_dirs: list[Path], profile: dict[str, Any] ) -> list[str]: """Return external content copied into generated output under profile policy.""" try: source = external_source.read_text(encoding="utf-8") generated = "\n".join( path.read_text(encoding="utf-8", errors="replace") for root in generated_dirs for path in root.rglob("*") if path.is_file() ) except OSError as exc: raise ProfileError(f"clean-room verification read error: {exc}") from exc raw_frontmatter, body = split_frontmatter(source) try: source_metadata = yaml.safe_load(raw_frontmatter) if raw_frontmatter else {} except yaml.YAMLError as exc: raise ProfileError(f"external skill frontmatter error: {exc}") from exc source_metadata = _mapping(source_metadata, "external skill frontmatter") clean_room = profile["clean_room"] policy = clean_room["external_content_policy"] if policy != "observe-structure-only": raise ProfileError( f"profile configuration error: unsupported external_content_policy {policy!r}" ) categories = set(clean_room["prohibited_copy_categories"]) detection = clean_room["copy_detection"] minimum_fragment = detection["minimum_fragment_characters"] ignored_exact = set(detection["ignored_exact_lines"]) ignored_prefixes = tuple(detection["ignored_line_prefixes"]) generated_folded = generated.casefold() generated_normalized = " ".join(generated.split()).casefold() copied: list[str] = [] if categories & {"prose", "prompts", "scripts", "examples"}: protected = { line.strip() for line in body.splitlines() if len(line.strip()) >= minimum_fragment and line.strip() not in ignored_exact and not line.lstrip().startswith(ignored_prefixes) } copied.extend(line for line in protected if line.casefold() in generated_folded) for field in detection["protected_frontmatter_fields"]: value = source_metadata.get(field) if not isinstance(value, str): continue normalized = " ".join(value.split()) if ( len(normalized) >= minimum_fragment and normalized.casefold() in generated_normalized ): copied.append(normalized) if "names" in categories: external_name = source_metadata.get("name", "") if ( isinstance(external_name, str) and len(external_name.strip()) >= detection["minimum_name_characters"] and external_name.strip().casefold() in generated_folded ): copied.append(external_name.strip()) return sorted(set(copied)) def emit_audit_tsv(skill_md: Path, profile: dict[str, Any]) -> None: """Emit shell-safe, validated profile/evaluation data for audit.sh.""" result = evaluation(skill_md, profile) print(f"profile_id\t{result['profile_id']}") print(f"kernel_max_lines\t{result['kernel_max_lines']}") print(f"line_count\t{result['line_count']}") print(f"trigger_forms\t{','.join(result['trigger_forms'])}") print(f"output_complete\t{str(result['output_complete']).lower()}") for component_id, present in result["output_components"].items(): print(f"output_component\t{component_id}\t{str(present).lower()}") for rule_id in profile["rule_order"]: rule = profile["rules"][rule_id] accepted = ",".join(rule.get("accepted_forms", [])) print(f"rule\t{rule_id}\t{rule['severity']}\t{accepted}") def main(argv: list[str] | None = None) -> int: """Validate a profile and optionally emit evaluation data for audit.sh.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo-root", type=Path, required=True) parser.add_argument("--profile-id", default=None) parser.add_argument("--audit-tsv", type=Path) parser.add_argument("--verify-clean-room", type=Path, metavar="EXTERNAL_SKILL_MD") parser.add_argument("--generated-dir", type=Path, action="append", default=[]) args = parser.parse_args(argv) try: profile = load_profile(args.repo_root, args.profile_id) if args.audit_tsv and args.verify_clean_room: raise ProfileError( "profile configuration error: choose one of --audit-tsv or --verify-clean-room" ) if args.verify_clean_room: if not args.generated_dir: raise ProfileError( "profile configuration error: --verify-clean-room requires --generated-dir" ) copied = clean_room_copies(args.verify_clean_room, args.generated_dir, profile) if copied: raise ProfileError( f"clean-room violation under profile {profile['id']}: copied external " f"content: {copied[0]}" ) print(profile["id"]) elif args.audit_tsv: emit_audit_tsv(args.audit_tsv, profile) else: print(profile["id"]) except ProfileError as exc: print(str(exc), file=sys.stderr) return 2 return 0 if __name__ == "__main__": raise SystemExit(main()) -
craft_score.py 12.2 KB
#!/usr/bin/env python3 """Advisory craft instrumentation for one skill package (audit Pass 4). Reports three advisory blocks over a skill's SKILL.md: 1. A 12-element craft score with named gaps. Elements are the cheaply machine-detectable authoring elements enumerated in references/skill-template.md (section 8). Presence is detected, never quality; scoring quality stays a fresh validator's judgment. 2. Provenance resolution: cited repo paths and .agents/ao verdict/intent digests (full or prefix...suffix abbreviated) must resolve; dead citations are named findings. 3. Loop safety: iteration prose lacking a checkable stop-condition phrase in the same section, and agent-dispatch loops lacking a budget phrase, are named findings. Everything here is advisory-only: this script never gates, and audit.sh embeds its output without letting it change exit codes or verdicts. HTML comments are stripped before detection so the init.sh scaffolding stubs (<!-- craft:... -->) never satisfy an element; only authored prose counts. """ from __future__ import annotations import argparse import json import re from pathlib import Path import yaml MAX_SCORE = 12 # Checkable stop-condition phrases: a bare "until <vague goal>" does not # count; the phrase must name a greppable boundary (count, exit code, # passing check). STOP_CONDITION = re.compile( r"(?i)(" r"stop[- ](when|after|if|condition)s?|stops (when|after)|halt (when|after)" r"|at most \d+|no more than \d+|max(imum)?\s*(of\s+)?\d+" r"|\d+\s+(iterations?|attempts?|passes|times|rounds)" r"|give up after|exit (when|0|code 0)" r"|until [^.\n]*\b(exit 0|exits 0|passes|pass\b|green\b|zero findings|\d+)" r")" ) LOOP_PROSE = re.compile(r"(?i)\b(repeat(ed|s)?|iterat(e|es|ing|ion|ions)|loop(s|ing)?)\b") DISPATCH_PROSE = re.compile( r"(?i)\b(agents?|subagents?|dispatch(es|ing)?|spawn(s|ed|ing)?|workers?|lanes?|swarm)\b" ) BUDGET_PHRASE = re.compile( r"(?i)\b(" r"budget|timebox|deadline" r"|at most \d+|no more than \d+|max(imum)?\s*(of\s+)?\d+" r"|\d+\s+(agents?|subagents?|workers?|lanes?|dispatches|attempts?|iterations?)" r")\b" ) DIGEST_ABBREV = re.compile(r"\b([0-9a-f]{6,63})(?:\.\.\.|…)([0-9a-f]{2,63})\b") DIGEST_PREFIX = re.compile(r"\b([0-9a-f]{8,63})(?:\.\.\.|…)(?![0-9a-f])") DIGEST_FULL = re.compile(r"\b[0-9a-f]{64}\b") REPO_PATH = re.compile( r"(?<![\w/.-])" r"((?:docs|scripts|skills|skills-codex|tests|cli|schemas|evidence|\.agentops|\.agents)" r"/[A-Za-z0-9._\-][A-Za-z0-9._/\-]*)" ) def frontmatter_and_body(text: str) -> tuple[dict, str]: parts = text.split("---", 2) if len(parts) != 3: return {}, text try: data = yaml.safe_load(parts[1]) or {} except yaml.YAMLError: data = {} if not isinstance(data, dict): data = {} return data, parts[2] def strip_html_comments(text: str) -> str: return re.sub(r"<!--.*?-->", "", text, flags=re.S) def split_sections(body: str) -> list[tuple[str, str]]: """Split body into (heading, section_text) pairs; preamble uses ''.""" sections: list[tuple[str, str]] = [] heading = "" lines: list[str] = [] for line in body.splitlines(): match = re.match(r"^#{1,6}\s+(.*)$", line) if match: sections.append((heading, "\n".join(lines))) heading = match.group(1).strip() lines = [] else: lines.append(line) sections.append((heading, "\n".join(lines))) return sections def resolve_digest(citation: str, digest_stems: list[str]) -> bool: if "..." in citation or "…" in citation: prefix, _, suffix = re.split(r"(\.\.\.|…)", citation, maxsplit=1) return any( stem.startswith(prefix) and (not suffix or stem.endswith(suffix)) for stem in digest_stems ) return citation in digest_stems def check_provenance(text: str, repo_root: Path, skill_dir: Path) -> dict: # Fenced code blocks are illustrative examples, not citations; extracting # from them produces false dead findings (e.g. "skills/example"). text = re.sub(r"```.*?```", "", text, flags=re.S) digest_stems = [ path.stem for pattern in ("verdicts", "intents") for path in sorted((repo_root / ".agents" / "ao" / pattern / "sha256").glob("*")) if path.is_file() ] citations: list[tuple[str, str]] = [] seen: set[str] = set() for regex in (DIGEST_ABBREV, DIGEST_PREFIX): for match in regex.finditer(text): token = match.group(0) if token not in seen: seen.add(token) citations.append((token, "digest")) for match in DIGEST_FULL.finditer(text): token = match.group(0) if token not in seen and not any(token in c for c, _ in citations): seen.add(token) citations.append((token, "digest")) for match in REPO_PATH.finditer(text): token = match.group(1).rstrip("./") if token and token not in seen: seen.add(token) citations.append((token, "path")) dead = [] resolved = 0 for citation, kind in citations: if kind == "digest": ok = resolve_digest(citation, digest_stems) else: ok = (repo_root / citation).exists() or (skill_dir / citation).exists() if ok: resolved += 1 else: dead.append({"citation": citation, "kind": kind}) return { "advisory": True, "citations": len(citations), "resolved": resolved, "dead": dead, } def check_loop_safety(sections: list[tuple[str, str]]) -> list[dict]: findings = [] for heading, text in sections: if not LOOP_PROSE.search(text): continue label = heading or "(preamble)" if not STOP_CONDITION.search(text): findings.append( { "type": "loop-missing-stop-condition", "section": label, "evidence": "iteration prose without a checkable stop-condition phrase in the same section", } ) if DISPATCH_PROSE.search(text) and not BUDGET_PHRASE.search(text): findings.append( { "type": "dispatch-loop-missing-budget", "section": label, "evidence": "agent-dispatch loop without a budget phrase in the same section", } ) return findings def detect_elements( description: str, body: str, sections: list[tuple[str, str]], provenance: dict, ) -> list[dict]: def grep(pattern: str, text: str) -> bool: return re.search(pattern, text, re.I) is not None named_loop = False for _, text in sections: if LOOP_PROSE.search(text) and STOP_CONDITION.search(text): named_loop = True break anti_pattern_paired = False for _, text in sections: if grep(r"\b(anti-pattern|avoid|never|don'?t|do not)\b", text) and grep( r"\b(instead|corrective|rather than|replace with)\b", text ): anti_pattern_paired = True break fenced = re.findall(r"```([a-zA-Z]*)\n(.*?)```", body, re.S) runnable = any( lang.lower() in ("bash", "sh", "shell", "console", "zsh") or re.search(r"(?m)^\s*(bash|python3|ao|sh)\s+\S", code) for lang, code in fenced ) router = grep(r"(?m)^#{1,6}\s.*\b(modes|routing|router)\b", body) or grep( r"(?m)^\|[^\n]*\b(mode|trigger)\b[^\n]*\|", body ) checks = [ ( "causal-insight-line", grep(r"(insight:|\*\*why:?\*\*|\bbecause\b|why this works)", body), "a line stating the causal mechanism (Insight:/Why:/because)", ), ( "named-failure-mode", grep(r"(failure mode|fails when|failure behavior|known failure)", body), "a named failure mode or failure-behavior section", ), ( "frozen-prompts", grep(r"(copy-paste-only|copy paste only|frozen prompt|verbatim prompt)", body), "a prompt block marked copy-paste-only/frozen/verbatim", ), ( "named-loop-stop-condition", named_loop, "loop prose with a checkable stop-condition phrase in the same section", ), ( "quantified-rules", grep( r"(at most \d|at least \d|no more than \d|within \d|max(imum)? (of )?\d" r"|<=\s?\d|>=\s?\d|\b\d+ (lines|files|attempts|iterations|passes|seconds|minutes|checks|bullets)\b)", body, ), "a rule with a number and unit or comparator", ), ( "negative-space", grep( r"(non-goal|not for\b|not ideal for|do not use|not when|out of scope|does not\b|never\b)", body, ), "explicit negative space (non-goals / not-for)", ), ( "anti-pattern-with-corrective", anti_pattern_paired, "an anti-pattern paired with a corrective (instead/corrective) in the same section", ), ( "provenance-citation", provenance["citations"] > 0 and provenance["resolved"] > 0, "at least one resolvable repo-path or .agents/ao digest citation", ), ( "measurable-done", grep( r"(done when|complete when|exit (code )?0|exits? 0\b|exits? nonzero|passes when|validator command)", body, ), "a machine-checkable done signal (exit code / done-when phrase)", ), ( "router-shape", router, "a modes/routing table or heading mapping triggers to entry points", ), ( "trigger-rich-description", grep(r"(triggers:|use when)", description or ""), "frontmatter description with Triggers:/Use when phrases", ), ( "runnable-commands", runnable, "a fenced block with runnable commands", ), ] return [ { "id": element_id, "present": bool(present), "evidence": ("found: " if present else "missing: ") + what, } for element_id, present, what in checks ] def craft_report(skill_dir: Path, repo_root: Path) -> dict: skill_md = skill_dir / "SKILL.md" if not skill_md.is_file(): raise SystemExit(f"SKILL.md not found: {skill_md}") raw = skill_md.read_text(encoding="utf-8") text = strip_html_comments(raw) fm, body = frontmatter_and_body(text) sections = split_sections(body) provenance = check_provenance(text, repo_root, skill_dir) loop_findings = check_loop_safety(sections) elements = detect_elements(str(fm.get("description") or ""), body, sections, provenance) score = sum(1 for element in elements if element["present"]) missing = [element["id"] for element in elements if not element["present"]] summary = f"craft {score}/{MAX_SCORE}" if missing: summary += "; missing: " + ", ".join(missing) return { "score": score, "max": MAX_SCORE, "advisory": True, "missing": missing, "elements": elements, "provenance": provenance, "loop_safety": {"advisory": True, "findings": loop_findings}, "summary": summary, } def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("skill_path") parser.add_argument( "--audit-block", action="store_true", help="Emit the advisory craft block embedded by audit.sh (same JSON as default).", ) parser.add_argument( "--repo-root", default=None, help="Repo root for provenance resolution (default: this script's repo).", ) args = parser.parse_args() script_repo = Path(__file__).resolve().parents[3] repo_root = Path(args.repo_root).resolve() if args.repo_root else script_repo report = craft_report(Path(args.skill_path).expanduser().resolve(), repo_root) print(json.dumps(report, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main()) -
heal.sh 3.4 KB
#!/usr/bin/env bash # One-pass structural audit for source skill packages. set -euo pipefail MODE=check STRICT=0 TARGETS=() while [[ $# -gt 0 ]]; do case "$1" in --check) MODE=check ;; --fix) MODE=fix ;; --strict) STRICT=1 ;; -h|--help) echo "usage: heal.sh [--check|--fix] [--strict] [skills/<slug> ...]" exit 0 ;; *) TARGETS+=("$1") ;; esac shift done SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="${HEAL_REPO_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}" REPO_ROOT="$(cd "$REPO_ROOT" && pwd -P)" if [[ ${#TARGETS[@]} -eq 0 ]]; then for path in "$REPO_ROOT/skills"/*; do [[ -d "$path" && -f "$path/SKILL.md" ]] && TARGETS+=("$path") done fi normalized=() for target in "${TARGETS[@]}"; do [[ "$target" = /* ]] || target="$REPO_ROOT/$target" [[ -d "$target" ]] || { echo "heal.sh: target does not exist: $target" >&2; exit 2; } [[ ! -L "$target" ]] || { echo "heal.sh: symlink targets are not accepted: $target" >&2; exit 2; } resolved="$(cd "$target" && pwd -P)" case "$(dirname "$resolved")" in "$REPO_ROOT/skills") ;; *) echo "heal.sh: target is not a direct skill package: $target" >&2; exit 2 ;; esac normalized+=("$resolved") done set +e python3 - "$REPO_ROOT" "${normalized[@]}" <<'PY' from pathlib import Path import re import sys import yaml repo = Path(sys.argv[1]) findings = [] for root in map(Path, sys.argv[2:]): skill = root / "SKILL.md" rel = root.relative_to(repo).as_posix() if not skill.is_file(): findings.append(("MISSING_SKILL", rel, "SKILL.md is missing")) continue text = skill.read_text(encoding="utf-8") parts = text.split("---", 2) if len(parts) != 3: findings.append(("INVALID_FRONTMATTER", rel, "leading YAML frontmatter is missing")) continue try: data = yaml.safe_load(parts[1]) or {} except yaml.YAMLError as exc: findings.append(("INVALID_FRONTMATTER", rel, str(exc).splitlines()[0])) continue slug = root.name if data.get("name") != slug: findings.append(("NAME_MISMATCH", rel, f"name must be {slug!r}")) if not isinstance(data.get("description"), str) or not data["description"].strip(): findings.append(("MISSING_DESC", rel, "description must be nonempty")) if data.get("skill_api_version") != 1: findings.append(("MISSING_API_VERSION", rel, "skill_api_version must be 1")) metadata = data.get("metadata") if not isinstance(metadata, dict) or not isinstance(metadata.get("disposition"), str) or not metadata["disposition"]: findings.append(("MISSING_DISPOSITION", rel, "metadata.disposition must be nonempty")) body = parts[2] for match in re.finditer(r"\]\((references|scripts)/([^\s)#?]+)", body): linked = root / match.group(1) / match.group(2) if not linked.exists(): findings.append(("DEAD_REF", rel, f"missing {linked.relative_to(root)}")) for code, path, message in findings: print(f"[{code}] {path}: {message}") sys.exit(1 if findings else 0) PY rc=$? set -e if [[ "$MODE" == fix ]]; then # Source behavior remains human-authored. Repair only owned projections. python3 "$REPO_ROOT/scripts/generate-skill-mesh.py" names="$(printf '%s\n' "${normalized[@]}" | sed 's#.*/##' | sort -u | paste -sd, -)" bash "$REPO_ROOT/scripts/codex-sync.sh" --force --only "$names" fi if [[ $rc -ne 0 && ( $STRICT -eq 1 || "$MODE" == fix ) ]]; then exit 1 fi exit 0 -
init.sh 5.2 KB
#!/usr/bin/env bash # Create one metadata-complete canonical skill source package. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="${SKILL_BUILDER_REPO_ROOT:-$(cd "$SCRIPT_DIR/../../.." && pwd)}" usage() { echo "usage: init.sh --scratch|--template|--external <slug> [--like <slug>|--from <path>]" >&2 exit 2 } [[ $# -ge 2 ]] || usage mode="$1" slug="$2" shift 2 [[ "$slug" =~ ^[a-z][a-z0-9-]*$ ]] || { echo "init.sh: slug must be lowercase-hyphen: $slug" >&2 exit 2 } source_hint="" case "$mode" in --scratch) [[ $# -eq 0 ]] || usage ;; --template) [[ $# -eq 2 && "$1" == "--like" ]] || usage source_hint="$2" [[ -f "$REPO_ROOT/skills/$source_hint/SKILL.md" ]] || { echo "init.sh: unknown template skill: $source_hint" >&2 exit 2 } ;; --external) [[ $# -eq 2 && "$1" == "--from" ]] || usage source_hint="$2" [[ -f "$source_hint" ]] || { echo "init.sh: external source does not exist: $source_hint" >&2 exit 2 } ;; *) usage ;; esac target="$REPO_ROOT/skills/$slug" [[ ! -e "$target" ]] || { echo "init.sh: target already exists: $target" >&2 exit 1 } tier="${SKILL_TIER:-execution}" dependencies="${SKILL_DEPENDENCIES:-[]}" capabilities="${SKILL_CAPABILITIES:-[\"${slug//-/_}\"]}" effects="${SKILL_EFFECTS:-[]}" python3 - "$dependencies" "$capabilities" "$effects" <<'PY' import json import sys for value in sys.argv[1:]: parsed = json.loads(value) if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): raise SystemExit("skill metadata lists must be JSON arrays of strings") PY mkdir -p "$target/scripts" # The <!-- craft:... --> stubs mirror the 12 craft elements enumerated in # references/skill-template.md section 7. The craft scorer strips HTML # comments, so a fresh scaffold scores low until the author replaces stubs # with real prose. cat >"$target/SKILL.md" <<EOF --- name: $slug description: 'TODO: state the behavior and concrete trigger phrases for $slug.' practices: [] skill_api_version: 1 hexagonal_role: supporting consumes: [] produces: [] context_rel: [] user-invocable: true metadata: tier: $tier dependencies: $dependencies capabilities: $capabilities effects: $effects canonical_status: canonical disposition: keep_specialist stability: experimental --- # /$slug TODO: Explain the bounded behavior this skill provides. <!-- craft:trigger-rich-description Put Triggers:/Use when phrases callers actually say in the frontmatter description. --> <!-- craft:causal-insight-line State the one causal insight (Insight:/Why:/a-because-clause) that makes this skill work. --> <!-- craft:named-failure-mode Name the concrete failure mode this skill exists to prevent. --> <!-- craft:router-shape Map trigger phrases to modes/entry points in a routing table when the skill has modes. --> ## Inputs TODO: List required inputs and explicit non-goals. <!-- craft:negative-space State what this skill is NOT for (non-goals / not-for / do-not-use-when). --> ## Procedure 1. TODO: Perform one bounded operation. 2. TODO: Check the output against the stated contract. 3. Report the result and stop. <!-- craft:named-loop-stop-condition If any step iterates, name the loop and give a checkable stop condition in the same section. --> <!-- craft:quantified-rules Quantify at least one rule with a number and unit. --> <!-- craft:anti-pattern-with-corrective Pair each anti-pattern with its corrective in the same section. --> <!-- craft:frozen-prompts Provide any reusable prompt as a fenced block marked copy-paste-only. --> <!-- craft:runnable-commands Include at least one fenced block with runnable commands. --> ## Output TODO: Define the artifact or response shape and how a caller checks it. <!-- craft:measurable-done Give a machine-checkable done signal (done-when phrase, exit 0, validator command). --> ## Checks - The output satisfies the declared behavior. - No undeclared side effect occurred. <!-- craft:provenance-citation Cite at least one resolvable repo path or .agents/ao verdict/intent digest grounding this skill. --> ## Failure behavior Report the concrete failure and stop. The caller owns any revision. EOF cat >"$target/scripts/validate.sh" <<'EOF' #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd "$SKILL_DIR/../.." && pwd)" exec bash "$REPO_ROOT/skills/skill-builder/scripts/heal.sh" --check --strict "$SKILL_DIR" EOF chmod +x "$target/scripts/validate.sh" mkdir -p "$REPO_ROOT/.agents/scratch/skill-builder" report="$REPO_ROOT/.agents/scratch/skill-builder/${slug}-build.json" python3 - "$report" "$mode" "$slug" "$source_hint" <<'PY' import json from pathlib import Path import sys path = Path(sys.argv[1]) mode = {"--scratch": "from-scratch", "--template": "from-template", "--external": "absorb-external"}[sys.argv[2]] payload = { "mode": mode, "skill_name": sys.argv[3], "files_created": [f"skills/{sys.argv[3]}/SKILL.md", f"skills/{sys.argv[3]}/scripts/validate.sh"], "structure_check_pass": False, } if sys.argv[4]: payload["source_hint"] = sys.argv[4] path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") PY echo "init.sh: created $target" -
scan_descriptions.py 21.2 KB
#!/usr/bin/env python3 """Corpus-wide skill description trigger scanner. The per-skill deep audit (skill-builder audit mode) runs `description-has-triggers` / `trigger-clarity` as WARN checks, so a missing trigger phrase never blocks a merge and the gap accumulates silently across the corpus. This scanner is the corpus-wide companion: it walks every `skills/*/SKILL.md`, applies the *same* three-form trigger detection as `skill-builder/scripts/audit.sh`, scores each description, and emits a prioritized remediation list with a suggested `Triggers:` stub for each skill that lacks one. Discovery in the runtime is pure LLM reasoning over the `description` field, so a missing trigger phrase is a material skill-selection risk, not cosmetic. See `skills/skill-builder/SKILL.md`. Usage: python3 scan_descriptions.py [SKILLS_DIR] [--json] [--strict] [--quiet] python3 scan_descriptions.py [SKILLS_DIR] --probe "<phrase>" [--json] python3 scan_descriptions.py [SKILLS_DIR] --list-probes Probe mode (`--probe "<phrase>"`) ranks every skill against the phrase using ONLY the deterministic lexical ranker below — no live model, no `claude -p`, no network — and asserts the skill that DECLARES the phrase in its `trigger_probes:` frontmatter list ranks #1. Output is byte-stable across runs. Exit codes: 0 every emitted profile check passes (or --strict not set); in --probe mode: the declaring skill ranks #1 for the phrase 1 one or more emitted profile checks is WARN/FAIL AND --strict is set; in --probe mode: the declaring skill does NOT rank #1 2 usage error (skills dir not found, or no skill declares the phrase) """ from __future__ import annotations import argparse import json import os import re import sys from dataclasses import dataclass, field from pathlib import Path try: from conformance_profile import ProfileError, load_profile, trigger_forms except ModuleNotFoundError as exc: ProfileError = ValueError # type: ignore[misc,assignment] load_profile = None # type: ignore[assignment] trigger_forms = None # type: ignore[assignment] _PROFILE_IMPORT_ERROR = exc else: _PROFILE_IMPORT_ERROR = None REPO_ROOT = Path(__file__).resolve().parents[3] # Stop-words stripped when deriving a suggested trigger stub from the name. _STOPWORDS = frozenset({"the", "a", "an", "for", "and", "to", "of", "with"}) @dataclass class SkillScan: """Result of scanning one SKILL.md for trigger quality.""" name: str path: Path description: str has_trigger: bool forms: list[str] = field(default_factory=list) score: int = 0 suggestion: str = "" profile_id: str = "" checks: list[dict[str, str]] = field(default_factory=list) def to_dict(self) -> dict: """Return a JSON-serializable view for --json / robot mode.""" return { "name": self.name, "path": str(self.path), "has_trigger": self.has_trigger, "forms": self.forms, "score": self.score, "suggestion": self.suggestion, "profile_id": self.profile_id, "checks": self.checks, } def split_frontmatter(text: str) -> tuple[str, str]: """Split a SKILL.md into (frontmatter, body). Empty frontmatter if absent.""" if not text.startswith("---"): return "", text parts = text.split("\n---", 1) if len(parts) != 2: return "", text frontmatter = parts[0][len("---") :] body = parts[1].lstrip("-\n") return frontmatter, body def parse_field(frontmatter: str, key: str) -> str: """Extract a single top-level scalar field's first line from frontmatter.""" match = re.search(rf"^{re.escape(key)}:\s*(.*)$", frontmatter, re.MULTILINE) return match.group(1).strip() if match else "" def description_block(frontmatter: str) -> str: """Return the full description value, including folded/literal continuations.""" lines = frontmatter.splitlines() out: list[str] = [] capturing = False for line in lines: if line.startswith("description:"): capturing = True out.append(line) continue if capturing: # A new top-level key (no leading whitespace, ends the block). if re.match(r"^[A-Za-z_-]+:", line): break out.append(line) return "\n".join(out) def count_trigger_list(frontmatter: str) -> int: """Count items under a `metadata.triggers:` (or `triggers:`) YAML list.""" lines = frontmatter.splitlines() in_list = False count = 0 for line in lines: if re.match(r"^\s+triggers:\s*$", line): in_list = True continue if in_list: if re.match(r"^\s+-\s+", line): count += 1 continue if re.match(r"^\s*[A-Za-z_-]+:", line): break return count def split_flow_items(inner: str) -> list[str]: """Split a simple YAML flow sequence body without breaking quoted commas.""" items: list[str] = [] current: list[str] = [] quote = "" i = 0 while i < len(inner): char = inner[i] if quote: if char == "\\" and quote == '"' and i + 1 < len(inner): current.append(inner[i + 1]) i += 2 continue if char == quote: if quote == "'" and i + 1 < len(inner) and inner[i + 1] == "'": current.append("'") i += 2 continue quote = "" else: current.append(char) elif char in ("'", '"'): quote = char elif char == ",": items.append("".join(current)) current = [] else: current.append(char) i += 1 items.append("".join(current)) return items def parse_trigger_probes(frontmatter: str) -> list[str]: """Return the items under a top-level `trigger_probes:` YAML list. Supports the flow form (`trigger_probes: ["a", "b"]`) and the block form (`trigger_probes:` followed by indented `- item` lines). Quotes are stripped; order is preserved. Purely lexical — no YAML library required so the scanner stays dependency-free and deterministic. """ lines = frontmatter.splitlines() probes: list[str] = [] for idx, line in enumerate(lines): flow = re.match(r"^trigger_probes:\s*\[(.*)\]\s*$", line) if flow: inner = flow.group(1).strip() if inner: for item in split_flow_items(inner): cleaned = item.strip().strip("'\"").strip() if cleaned: probes.append(cleaned) return probes if re.match(r"^trigger_probes:\s*$", line): for follow in lines[idx + 1 :]: item = re.match(r"^\s+-\s+(.*)$", follow) if item: cleaned = item.group(1).strip().strip("'\"").strip() if cleaned: probes.append(cleaned) continue if re.match(r"^\S", follow): break return probes return probes _WORD_RE = re.compile(r"[a-z0-9]+") def _tokens(text: str) -> list[str]: """Lowercase alphanumeric tokens, in order, for deterministic scoring.""" return _WORD_RE.findall(text.lower()) def lexical_score(phrase: str, scan: SkillScan) -> tuple: """Deterministic lexical relevance of one skill to a probe phrase. Pure token math over the skill's own SEARCHABLE TEXT (name + description) — no model, no network, and deliberately NOT a function of the skill's `trigger_probes:` declaration. The declaration only identifies *which* skill is expected to win; the ranking itself is earned purely by lexical overlap, so a skill that stops describing its phrase genuinely drops in rank. Returns a tuple sort key (higher is more relevant); ties break by name so the ranking is total and byte-stable. Signals, in priority order: 1. fraction of phrase tokens present in the searchable text (coverage), 2. raw count of phrase-token hits, 3. name-token overlap (a phrase word that is also a name word). """ phrase_tokens = _tokens(phrase) name_tokens = set(_tokens(scan.name)) haystack = " ".join([scan.name, scan.description]) hay_tokens = _tokens(haystack) hay_set = set(hay_tokens) if phrase_tokens: present = sum(1 for t in phrase_tokens if t in hay_set) coverage = present / len(phrase_tokens) hits = sum(hay_tokens.count(t) for t in set(phrase_tokens)) else: coverage = 0.0 hits = 0 name_overlap = sum(1 for t in set(phrase_tokens) if t in name_tokens) return (coverage, hits, name_overlap) @dataclass class ProbeResult: """One skill's deterministic rank for a probe phrase.""" name: str score_key: tuple declares_phrase: bool def to_dict(self) -> dict: """JSON-serializable view (score_key as a list for stable output).""" return { "name": self.name, "score_key": list(self.score_key), "declares_phrase": self.declares_phrase, } def probe_corpus( skills_dir: Path, phrase: str, profile: dict | None = None ) -> list[ProbeResult]: """Rank every skill against `phrase` using the deterministic lexical ranker. Sorted by descending score, then ascending name — a total, byte-stable order. Each result records whether that skill declares the phrase in its `trigger_probes:` list. """ ranked: list[ProbeResult] = [] for skill_md in sorted(skills_dir.glob("*/SKILL.md")): if profile is None: try: text = skill_md.read_text(encoding="utf-8") except OSError: continue frontmatter, _ = split_frontmatter(text) scan = SkillScan( name=parse_field(frontmatter, "name") or skill_md.parent.name, path=skill_md, description=description_block(frontmatter), has_trigger=False, ) else: scan = scan_skill(skill_md, profile) if scan is None: continue frontmatter, _ = split_frontmatter(skill_md.read_text(encoding="utf-8")) probes = parse_trigger_probes(frontmatter) key = lexical_score(phrase, scan) declares = phrase.strip().lower() in {p.strip().lower() for p in probes} ranked.append(ProbeResult(name=scan.name, score_key=key, declares_phrase=declares)) def sort_key(result: ProbeResult) -> tuple: # Descending score (negate the numeric components), ascending name. return (tuple(-x for x in result.score_key), result.name) ranked.sort(key=sort_key) return ranked def render_probe(phrase: str, ranked: list[ProbeResult]) -> str: """Render a deterministic human-readable probe report.""" declaring = [r.name for r in ranked if r.declares_phrase] lines = [ "# Trigger probe", "", f"- Phrase: {phrase!r}", f"- Skills ranked: {len(ranked)}", f"- Declaring skills: {', '.join(declaring) if declaring else '(none)'}", "", "## Ranking (deterministic lexical, no model)", "", "| Rank | Skill | Declares | Score key |", "|------|-------|----------|-----------|", ] for i, r in enumerate(ranked, start=1): mark = "yes" if r.declares_phrase else "" lines.append(f"| {i} | `{r.name}` | {mark} | {list(r.score_key)} |") return "\n".join(lines) def detect_trigger(text: str, profile: dict) -> list[str]: """Return canonical trigger form IDs from the selected profile semantics.""" if trigger_forms is None: raise ProfileError(f"profile configuration loader missing: {_PROFILE_IMPORT_ERROR}") return trigger_forms(text, profile) def score_trigger(description: str) -> int: """Score 0-3, mirroring skill-builder/scripts/score_agentops_skill.py.""" signals = sum( marker.lower().strip("*").rstrip(":") in description.lower() for marker in ("Use when", "Triggers", "Perfect for") ) return min(3, int(bool(description.strip())) + signals) def suggest_triggers(name: str, description: str) -> str: """Derive a deterministic `Triggers:` stub from the skill name + first verb.""" tokens = [t for t in name.split("-") if t not in _STOPWORDS] spaced = " ".join(tokens) first_sentence = re.split(r"[.\n]", description.strip(), maxsplit=1)[0] words = first_sentence.split() verb = words[0].lower().strip("'\"") if words else "" candidates = [name, spaced] # Only add a verb phrase when the verb adds a word not already in the name. if verb and tokens and verb not in tokens: candidates.append(f"{verb} {tokens[-1]}") seen: list[str] = [] for phrase in candidates: cleaned = " ".join(dict.fromkeys(phrase.strip().lower().split())) if cleaned and cleaned not in seen: seen.append(cleaned) quoted = ", ".join(f'"{p}"' for p in seen) return f"Triggers: {quoted}" def scan_skill(skill_md: Path, profile: dict | None = None) -> SkillScan | None: """Scan one SKILL.md. Returns None if the file is unreadable/empty.""" try: text = skill_md.read_text(encoding="utf-8") except OSError: return None if profile is None: if load_profile is None: raise ProfileError(f"profile configuration loader missing: {_PROFILE_IMPORT_ERROR}") profile = load_profile(REPO_ROOT, os.environ.get("SKILL_CONFORMANCE_PROFILE_ID")) frontmatter, _body = split_frontmatter(text) if re.search(r"^implementation:\s*false\s*$", frontmatter, re.MULTILINE): return None name = parse_field(frontmatter, "name") or skill_md.parent.name description = description_block(frontmatter) forms = detect_trigger(text, profile) has_trigger = bool(forms) rules = profile["rules"] checks = [] for rule_id in ("description-has-triggers", "trigger-clarity"): accepted = rules[rule_id].get("accepted_forms", profile["trigger_forms"]["accepted"]) passed = any(form in accepted for form in forms) severity = rules[rule_id]["severity"] checks.append( { "id": rule_id, "severity": severity, "status": "pass" if passed else severity.lower(), } ) scan = SkillScan( name=name, path=skill_md, description=description, has_trigger=has_trigger, forms=forms, score=score_trigger(description), profile_id=profile["id"], checks=checks, ) if not has_trigger: scan.suggestion = suggest_triggers(name, parse_field(frontmatter, "description")) return scan def scan_corpus(skills_dir: Path, profile: dict | None = None) -> list[SkillScan]: """Scan every `<skill>/SKILL.md` under skills_dir, sorted by name.""" results: list[SkillScan] = [] for skill_md in sorted(skills_dir.glob("*/SKILL.md")): scan = scan_skill(skill_md, profile) if scan is not None: results.append(scan) return results def aggregate_verdict(results: list[SkillScan]) -> str: """Derive the scanner verdict solely from emitted profile check statuses.""" statuses = { check["status"] for result in results for check in result.checks } if "fail" in statuses: return "FAIL" if any(status != "pass" for status in statuses): return "WARN" return "PASS" def list_probe_pairs(skills_dir: Path) -> list[tuple[str, str]]: """Return every (skill-id, probe-phrase) pair declared in the corpus. The skill-id is the SKILL.md's parent directory name (matching what the rest of the tooling keys on). Phrases come from the SAME `parse_trigger_probes` parser used by --probe, so any downstream consumer that wants the parsed pairs reuses this one parser instead of reimplementing the YAML walk. Sorted by (skill-id, phrase) for byte-stable output. """ pairs: list[tuple[str, str]] = [] for skill_md in sorted(skills_dir.glob("*/SKILL.md")): try: text = skill_md.read_text(encoding="utf-8") except OSError: continue frontmatter, _body = split_frontmatter(text) sid = skill_md.parent.name for phrase in parse_trigger_probes(frontmatter): pairs.append((sid, phrase)) return sorted(set(pairs)) def render_markdown(results: list[SkillScan], profile_id: str = "") -> str: """Render a human-readable remediation report.""" total = len(results) missing = [r for r in results if not r.has_trigger] selected_profile = profile_id or (results[0].profile_id if results else "unknown") verdict = aggregate_verdict(results) lines = [ "# Skill description trigger scan", "", f"- Profile: **{selected_profile}**", f"- Verdict: **{verdict}**", f"- Skills scanned: **{total}**", f"- With trigger marker: **{total - len(missing)}**", f"- Missing trigger marker: **{len(missing)}** " f"({(len(missing) / total * 100):.0f}%)" if total else "- Missing: 0", "", ] if not missing: lines.append("All descriptions carry a trigger marker. ✅") return "\n".join(lines) lines += [ "## Remediation backlog (add a trigger marker to each)", "", "| Skill | Score | Suggested stub |", "|-------|-------|----------------|", ] for r in missing: lines.append(f"| `{r.name}` | {r.score}/3 | `{r.suggestion}` |") return "\n".join(lines) def _run_probe( skills_dir: Path, phrase: str, *, profile: dict | None, json_mode: bool, quiet: bool, ) -> int: """Drive --probe: rank the corpus and assert the declaring skill wins. Returns 2 if no skill declares the phrase (a usage error — nothing to assert), 0 if the declaring skill ranks #1, 1 otherwise. """ ranked = probe_corpus(skills_dir, phrase, profile) declaring = [r for r in ranked if r.declares_phrase] top = ranked[0] if ranked else None declarer_is_top = bool(top and top.declares_phrase) if json_mode: payload = { "phrase": phrase, "ranked": len(ranked), "declaring": [r.name for r in declaring], "top": top.name if top else None, "declarer_is_top": declarer_is_top, "skills": [r.to_dict() for r in ranked], } print(json.dumps(payload, indent=2, sort_keys=True)) elif not quiet: print(render_probe(phrase, ranked)) if not declaring: if not json_mode: print(f"error: no skill declares the probe phrase: {phrase!r}", file=sys.stderr) return 2 return 0 if declarer_is_top else 1 def main(argv: list[str] | None = None) -> int: """CLI entry point.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "skills_dir", nargs="?", default="skills", help="Path to the skills/ directory (default: skills)", ) parser.add_argument("--json", action="store_true", help="Emit JSON (robot mode)") parser.add_argument( "--strict", action="store_true", help="Exit 1 if any emitted profile check is non-pass" ) parser.add_argument("--quiet", action="store_true", help="Suppress the human report") parser.add_argument( "--probe", metavar="PHRASE", default=None, help="Deterministic lexical probe: assert the skill that declares PHRASE " "in trigger_probes: ranks #1 (no live model, no network)", ) parser.add_argument( "--list-probes", action="store_true", help="Emit every declared (skill-id<TAB>phrase) pair, one per line, using " "the SAME parser as --probe (so consumers don't reimplement the YAML walk)", ) args = parser.parse_args(argv) skills_dir = Path(args.skills_dir) if not skills_dir.is_dir(): print(f"error: skills dir not found: {skills_dir}", file=sys.stderr) return 2 if args.list_probes: for sid, phrase in list_probe_pairs(skills_dir): print(f"{sid}\t{phrase}") return 0 if args.probe is not None: return _run_probe( skills_dir, args.probe, profile=None, json_mode=args.json, quiet=args.quiet, ) if load_profile is None: print(f"profile configuration loader missing: {_PROFILE_IMPORT_ERROR}", file=sys.stderr) return 2 try: profile = load_profile(REPO_ROOT, os.environ.get("SKILL_CONFORMANCE_PROFILE_ID")) except ProfileError as exc: print(str(exc), file=sys.stderr) return 2 results = scan_corpus(skills_dir, profile) missing = [r for r in results if not r.has_trigger] verdict = aggregate_verdict(results) if args.json: payload = { "profile_id": profile["id"], "verdict": verdict, "scanned": len(results), "missing": len(missing), "skills": [r.to_dict() for r in results], } print(json.dumps(payload, indent=2)) elif not args.quiet: print(render_markdown(results, profile["id"])) return 1 if (args.strict and verdict != "PASS") else 0 if __name__ == "__main__": sys.exit(main()) -
score_agentops_skill.py 12 KB
#!/usr/bin/env python3 """Score static package readiness for an AgentOps skill.""" from __future__ import annotations import argparse import json import os import re from pathlib import Path import yaml CATEGORIES = [ "trigger_quality", "kernel_clarity", "progressive_disclosure", "helper_scripts", "validation", "self_test", "assets_templates", "subagents_roles", "safety_boundaries", "packaging", ] def frontmatter(text: str) -> dict: match = re.match(r"^---\n(.*?)\n---", text, re.S) if not match: return {} try: data = yaml.safe_load(match.group(1)) or {} except yaml.YAMLError: return {} return data if isinstance(data, dict) else {} def count_files(path: Path, *parts: str) -> int: target = path.joinpath(*parts) if not target.exists(): return 0 return sum(1 for p in target.rglob("*") if p.is_file()) def has_named_script(path: Path, patterns: tuple[str, ...]) -> bool: scripts = path / "scripts" if not scripts.exists(): return False for script in scripts.rglob("*"): if not script.is_file(): continue name = script.name.lower() if any(pattern in name for pattern in patterns): return True return False def collect_metrics(path: Path, text: str) -> dict: lines = text.splitlines() scripts_dir = path / "scripts" executable_scripts = 0 if scripts_dir.exists(): executable_scripts = sum( 1 for p in scripts_dir.rglob("*") if p.is_file() and os.access(p, os.X_OK) ) return { "total_files": sum(1 for p in path.rglob("*") if p.is_file()), "skill_md_lines": len(lines), "headings": len([line for line in lines if line.startswith("#")]), "reference_links": len(re.findall(r"references/", text)), "reference_files": count_files(path, "references"), "script_files": count_files(path, "scripts"), "asset_files": count_files(path, "assets"), "subagent_files": count_files(path, "subagents"), "self_test_exists": (path / "SELF-TEST.md").exists(), "symlinks": sum(1 for p in path.rglob("*") if p.is_symlink()), "executable_scripts": executable_scripts, } def score_trigger(description: str) -> tuple[int, str]: if not description: return 0, "Description missing." lowered = description.lower() if any(term in lowered for term in ("not for", "do not use", "not when", "only when")): return 3, "Description contains a literal false-positive boundary phrase." if "triggers:" in lowered or "use when" in lowered: return 2, "Description contains a literal trigger marker." return 1, "Description is present without a literal trigger or boundary marker." def score_kernel(metrics: dict) -> tuple[int, str]: lines = metrics["skill_md_lines"] headings = metrics["headings"] if lines <= 220 and headings >= 3: score = 3 elif lines <= 500 and headings >= 2: score = 2 elif lines <= 800: score = 1 else: score = 0 return score, f"SKILL.md has {lines} lines and {headings} headings." def score_progressive_disclosure(metrics: dict) -> tuple[int, str]: reference_files = metrics["reference_files"] reference_links = metrics["reference_links"] if not reference_files and metrics["skill_md_lines"] <= 100: return 2, "SKILL.md is at most 100 lines with no reference files; loading semantics are not evaluated." score = min(3, (1 if reference_files else 0) + min(2, reference_links)) return score, f"{reference_files} reference files, {reference_links} direct reference links." def score_helper_scripts(path: Path, metrics: dict) -> tuple[int, str]: script_files = metrics["script_files"] if not script_files: return 1, "No helper scripts are visible; necessity is not inferred." recognized_helper = has_named_script(path, ("validate", "check", "audit", "score", "doctor")) score = 2 if recognized_helper else 1 if script_files >= 2 and score == 2: score = 3 return score, f"{script_files} script files; recognized helper name={int(recognized_helper)}." def score_validation(path: Path, body: str, metrics: dict) -> tuple[int, str]: validation_terms = ("validate", "test", "check", "lint", "verify", "heal.sh") keyword_signal = int(any(term in body.lower() for term in validation_terms)) named_helper = int(has_named_script(path, ("validate", "check", "test", "audit"))) self_test = int(metrics["self_test_exists"]) score = keyword_signal + named_helper + self_test note = ( f"keyword signal={keyword_signal}, recognized helper={named_helper}, " f"SELF-TEST.md={self_test}." ) return min(3, score), note def score_self_test(path: Path, metrics: dict) -> tuple[int, str]: if not metrics["self_test_exists"]: if any(path.rglob("*.feature")): return 2, "At least one .feature file is present." return 1, "No focused self-test or feature artifact is visible." self_test = (path / "SELF-TEST.md").read_text(encoding="utf-8").lower() score = min( 3, 1 + int("trigger" in self_test) + int("non-trigger" in self_test or "failure" in self_test), ) return score, "SELF-TEST.md present." def score_assets(path: Path, metrics: dict) -> tuple[int, str]: asset_files = metrics["asset_files"] if not asset_files: return 1, "No asset files are visible; necessity is not inferred." template_named = any( "template" in p.name.lower() for p in (path / "assets").rglob("*") if p.is_file() ) score = 3 if template_named else 2 return score, f"{asset_files} asset files; template-named file={int(template_named)}." def score_subagents(metrics: dict) -> tuple[int, str]: subagent_files = metrics["subagent_files"] if not subagent_files: return 1, "No subagent files are visible; necessity is not inferred." score = 2 if subagent_files < 3 else 3 return score, f"{subagent_files} subagent files." def score_safety(body: str) -> tuple[int, str]: safety_terms = ("do not", "never", "forbidden", "non-goal", "scope", "clean-room", "auth") safety_hits = sum(term in body.lower() for term in safety_terms) return min(3, safety_hits), f"{safety_hits} safety boundary signals." def score_packaging(metrics: dict) -> tuple[int, str]: score = 0 if metrics["total_files"] <= 50 and metrics["symlinks"] == 0: score += 2 if metrics["script_files"] == 0 or metrics["executable_scripts"] > 0: score += 1 note = ( f"{metrics['total_files']} files, {metrics['symlinks']} symlinks, " f"{metrics['executable_scripts']} executable scripts." ) return min(3, score), note def add_score( scores: dict[str, int], notes: dict[str, str], category: str, result: tuple[int, str], ) -> None: scores[category], notes[category] = result def readiness_rating(total: int) -> str: """Map a 0-30 static package-readiness score to its advisory band.""" if total >= 27: return "S" if total >= 21: return "A" if total >= 11: return "B" return "C" def score_skill(path: Path) -> dict: skill_md = path / "SKILL.md" if not skill_md.exists(): raise SystemExit(f"SKILL.md not found: {skill_md}") text = skill_md.read_text(encoding="utf-8") fm = frontmatter(text) body = re.sub(r"^---\n.*?\n---\n?", "", text, flags=re.S) metrics = collect_metrics(path, text) scores: dict[str, int] = {} notes: dict[str, str] = {} add_score(scores, notes, "trigger_quality", score_trigger(fm.get("description", ""))) add_score(scores, notes, "kernel_clarity", score_kernel(metrics)) add_score(scores, notes, "progressive_disclosure", score_progressive_disclosure(metrics)) add_score(scores, notes, "helper_scripts", score_helper_scripts(path, metrics)) add_score(scores, notes, "validation", score_validation(path, body, metrics)) add_score(scores, notes, "self_test", score_self_test(path, metrics)) add_score(scores, notes, "assets_templates", score_assets(path, metrics)) add_score(scores, notes, "subagents_roles", score_subagents(metrics)) add_score(scores, notes, "safety_boundaries", score_safety(body)) add_score(scores, notes, "packaging", score_packaging(metrics)) total = sum(scores.values()) rating = readiness_rating(total) gaps = [ {"category": category, "score": scores[category], "note": notes[category]} for category in CATEGORIES if scores[category] < 2 ] return { "skill": str(path), "name": path.name, "scope": "static-package-readiness", "safety_gate_evaluated": False, "effectiveness_evaluated": False, "total_score": total, "max_score": 30, "rating": rating, "scores": scores, "notes": notes, "categories": [ {"category": category, "score": scores[category], "reason": notes[category]} for category in CATEGORIES ], "gaps": gaps, "metrics": { "total_files": metrics["total_files"], "skill_md_lines": metrics["skill_md_lines"], "reference_files": metrics["reference_files"], "script_files": metrics["script_files"], "asset_files": metrics["asset_files"], "subagent_files": metrics["subagent_files"], "self_test_exists": metrics["self_test_exists"], "symlinks": metrics["symlinks"], "executable_scripts": metrics["executable_scripts"], }, } def audit_block(report: dict) -> dict: """Compact static-readiness object for the deep audit report (Pass 3). Mirrors the rubric schema block: per-category 0-3 score plus an explainable reason, the 0-30 total, max, and the C/B/A/S readiness band. It is derived only from directory contents and cannot evaluate safety or effectiveness. """ return { "scope": report["scope"], "safety_gate_evaluated": report["safety_gate_evaluated"], "effectiveness_evaluated": report["effectiveness_evaluated"], "total_score": report["total_score"], "max_score": report["max_score"], "rating": report["rating"], "advisory": True, "categories": report["categories"], } def markdown_report(report: dict) -> str: lines = [ f"# Static Skill Package Readiness: {report['name']}", "", f"Static score: {report['total_score']}/{report['max_score']} ({report['rating']})", "", "This score does not evaluate the safety gate or behavioral effectiveness.", "", "## Category Scores", "", "| Category | Score | Note |", "|---|---:|---|", ] for category in CATEGORIES: lines.append( f"| `{category}` | {report['scores'][category]} | {report['notes'][category]} |" ) lines.extend(["", "## Highest Leverage Gaps", ""]) if report["gaps"]: for gap in report["gaps"]: lines.append(f"- `{gap['category']}` ({gap['score']}): {gap['note']}") else: lines.append("- No category scored below 2.") lines.extend(["", "## Metrics", "", "```json", json.dumps(report["metrics"], indent=2), "```"]) return "\n".join(lines) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("skill_path") group = parser.add_mutually_exclusive_group() group.add_argument("--markdown", action="store_true", help="Emit a markdown report.") group.add_argument( "--audit-block", action="store_true", help="Emit the compact rubric block consumed by the skill-builder deep audit Pass 3.", ) args = parser.parse_args() report = score_skill(Path(args.skill_path).expanduser().resolve()) if args.markdown: print(markdown_report(report)) elif args.audit_block: print(json.dumps(audit_block(report), indent=2)) else: print(json.dumps(report, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main()) -
test-authoring-mutations.sh 3.3 KB
#!/usr/bin/env bash # test-authoring-mutations.sh — proves the advisory authoring scanner detects # prose degradation (references/authoring-doctrine.md failure modes). # # Baseline: a doctrine-clean fixture yields zero authoring findings. Each # mutation introduces exactly one failure mode and MUST surface the # corresponding named finding. # # Single documented command: # bash skills/skill-builder/scripts/test-authoring-mutations.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" AUTHORING_PY="$SCRIPT_DIR/authoring_scan.py" FIX="$(cd "$(mktemp -d)" && pwd -P)" trap 'rm -rf "$FIX"' EXIT fail() { echo "test-authoring-mutations: FAIL — $1" >&2; exit 1; } count_of() { # count_of <dir> <finding-id> python3 "$AUTHORING_PY" "$1" \ | python3 -c "import json,sys; print(json.load(sys.stdin)['counts']['$2'])" } total_of() { python3 "$AUTHORING_PY" "$1" \ | python3 -c "import json,sys; print(len(json.load(sys.stdin)['findings']))" } # --- Baseline fixture: doctrine-clean --------------------------------------- BASE="$FIX/base" mkdir -p "$BASE" cat >"$BASE/SKILL.md" <<'EOF' --- name: base description: 'Refine a draft. Triggers: "refine draft".' --- # base Read every reference file before editing. Edit the source and regenerate; never edit generated files directly. ## Workflow ### Gather Collect the inputs. Done when every input path resolves. ### Apply Make the edits. Done when the checker exits 0. EOF (( $(total_of "$BASE") == 0 )) \ || fail "baseline fixture unexpectedly has authoring findings" # --- Mutation 1: introduce a no-op phrase ----------------------------------- MUT1="$FIX/mut1" mkdir -p "$MUT1" sed 's/Read every reference file before editing\./Be thorough when editing./' \ "$BASE/SKILL.md" >"$MUT1/SKILL.md" (( $(count_of "$MUT1" noop-phrase) >= 1 )) \ || fail "no-op phrase mutation not surfaced as noop-phrase" # --- Mutation 2: strip the positive counterpart from a prohibition ---------- MUT2="$FIX/mut2" mkdir -p "$MUT2" python3 - "$BASE/SKILL.md" "$MUT2/SKILL.md" <<'PY' import sys text = open(sys.argv[1]).read() text = text.replace( "Read every reference file before editing. Edit the source and regenerate;\nnever edit generated files directly.", "Never edit generated files.", ) open(sys.argv[2], "w").write(text) PY (( $(count_of "$MUT2" negation-without-positive) >= 1 )) \ || fail "bare prohibition mutation not surfaced as negation-without-positive" # --- Mutation 3: strip a done condition from a workflow subphase ------------ MUT3="$FIX/mut3" mkdir -p "$MUT3" sed 's/Collect the inputs\. Done when every input path resolves\./Collect the inputs./' \ "$BASE/SKILL.md" >"$MUT3/SKILL.md" (( $(count_of "$MUT3" step-missing-done-condition) == 1 )) \ || fail "stripped done condition not surfaced as step-missing-done-condition" # --- Clearing direction: adding the done condition back clears the finding -- MUT4="$FIX/mut4" mkdir -p "$MUT4" sed 's/Collect the inputs\./Collect the inputs. Done when every input path resolves./' \ "$MUT3/SKILL.md" >"$MUT4/SKILL.md" (( $(count_of "$MUT4" step-missing-done-condition) == 0 )) \ || fail "restored done condition did not clear the finding" echo "authoring mutation detection: PASS (baseline 0 findings; noop, negation, done-condition mutations each surfaced; restore clears)" -
test-craft-mutations.sh 3.2 KB
#!/usr/bin/env bash # test-craft-mutations.sh — proves the advisory craft scorer detects degradation. # # Baseline: a craft-rich fixture scores N/12. Mutations that strip a stop # condition or an anti-pattern corrective MUST drop the score and name the # lost element as a gap. Runs independently of test-mutation-boundaries.sh # (which has a known pre-existing failure at its first assertion, tracked # separately; do not conflate the two). # # Single documented command: # bash skills/skill-builder/scripts/test-craft-mutations.sh set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" CRAFT_PY="$SCRIPT_DIR/craft_score.py" FIX="$(cd "$(mktemp -d)" && pwd -P)" trap 'rm -rf "$FIX"' EXIT fail() { echo "test-craft-mutations: FAIL — $1" >&2; exit 1; } score_of() { python3 "$CRAFT_PY" "$1" --repo-root "$REPO_ROOT" \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["score"])' } missing_of() { python3 "$CRAFT_PY" "$1" --repo-root "$REPO_ROOT" \ | python3 -c 'import json,sys; print(",".join(json.load(sys.stdin)["missing"]))' } # --- Baseline fixture: rich in the two elements under mutation ------------- BASE="$FIX/base" mkdir -p "$BASE" cat >"$BASE/SKILL.md" <<'EOF' --- name: base description: 'Refine a draft. Triggers: "refine draft", "polish draft".' --- # base Insight: drafts converge because each pass removes one named defect. ## Refinement loop Repeat the review pass. Stop after at most 3 passes or when the checker exits 0, whichever comes first. Avoid rewriting the whole draft in one pass; instead change one section per pass. ## Failure behavior Fails when the checker never reaches exit 0 within the pass budget. EOF baseline="$(score_of "$BASE")" baseline_missing="$(missing_of "$BASE")" [[ "$baseline_missing" != *"named-loop-stop-condition"* ]] \ || fail "baseline unexpectedly missing named-loop-stop-condition" [[ "$baseline_missing" != *"anti-pattern-with-corrective"* ]] \ || fail "baseline unexpectedly missing anti-pattern-with-corrective" # --- Mutation 1: strip the stop condition ---------------------------------- MUT1="$FIX/mut1" mkdir -p "$MUT1" sed -e 's/Stop after at most 3 passes or when the checker/Keep going until it feels done./' \ -e '/^exits 0, whichever comes first\.$/d' \ "$BASE/SKILL.md" >"$MUT1/SKILL.md" mut1="$(score_of "$MUT1")" (( mut1 < baseline )) \ || fail "stripping the stop condition did not drop the score (baseline=$baseline mutated=$mut1)" [[ "$(missing_of "$MUT1")" == *"named-loop-stop-condition"* ]] \ || fail "stop-condition mutation not named as a gap" # --- Mutation 2: strip the anti-pattern corrective -------------------------- MUT2="$FIX/mut2" mkdir -p "$MUT2" sed -e '/^Avoid rewriting the whole draft in one pass; instead change one section$/d' \ -e '/^per pass\.$/d' \ "$BASE/SKILL.md" >"$MUT2/SKILL.md" mut2="$(score_of "$MUT2")" (( mut2 < baseline )) \ || fail "stripping the anti-pattern corrective did not drop the score (baseline=$baseline mutated=$mut2)" [[ "$(missing_of "$MUT2")" == *"anti-pattern-with-corrective"* ]] \ || fail "anti-pattern mutation not named as a gap" echo "craft mutation detection: PASS (baseline $baseline/12; stop-condition strip -> $mut1/12; anti-pattern strip -> $mut2/12)" -
test-mutation-boundaries.sh 2.5 KB
#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" HEAL="$SCRIPT_DIR/heal.sh" AUDIT="$SCRIPT_DIR/audit.sh" FIX="$(cd "$(mktemp -d)" && pwd -P)" trap 'rm -rf "$FIX"' EXIT digest_tree() { find "$1" -type f -exec shasum -a 256 {} + | LC_ALL=C sort | shasum -a 256 | awk '{print $1}' } write_fixture_skill() { local path="$1" name="$2" mkdir -p "$path" printf '%s\n' '---' "name: $name" "description: Fixture $name." '---' "# $name" >"$path/SKILL.md" } expect_check_accepts() { local spelling="$1" rc set +e HEAL_REPO_ROOT="$FIX" bash "$HEAL" --check "$spelling" >/dev/null 2>&1 rc=$? set -e [[ "$rc" -eq 0 ]] } expect_rejected_unchanged() { local spelling="$1" before after rc before="$(digest_tree "$FIX")" set +e HEAL_REPO_ROOT="$FIX" bash "$HEAL" --fix "$spelling" >/dev/null 2>&1 rc=$? set -e after="$(digest_tree "$FIX")" [[ "$rc" -eq 2 && "$before" == "$after" ]] } mkdir -p "$FIX/skills" write_fixture_skill "$FIX/skills/target" target write_fixture_skill "$FIX/skills/sibling" sibling sibling_before="$(shasum -a 256 "$FIX/skills/sibling/SKILL.md" | awk '{print $1}')" set +e HEAL_REPO_ROOT="$FIX" bash "$HEAL" --fix skills/target >/dev/null 2>&1 fix_rc=$? set -e [[ "$fix_rc" -eq 1 ]] grep -q '^skill_api_version: 1$' "$FIX/skills/target/SKILL.md" if grep -q '^skill_api_version:' "$FIX/skills/sibling/SKILL.md"; then exit 1; fi [[ "$(shasum -a 256 "$FIX/skills/sibling/SKILL.md" | awk '{print $1}')" == "$sibling_before" ]] expect_check_accepts skills/target expect_check_accepts ./skills/target expect_check_accepts "$FIX/skills/target" write_fixture_skill "$FIX/outside" outside ln -s "$FIX/skills/target" "$FIX/skills/target-alias" ln -s "$FIX/outside" "$FIX/skills/outside-alias" ln -s "$FIX" "$FIX/repo-alias" expect_rejected_unchanged skills/target/../../outside expect_rejected_unchanged "$FIX/outside" expect_rejected_unchanged skills/target-alias expect_rejected_unchanged skills/outside-alias expect_rejected_unchanged "$FIX/repo-alias/skills/target" expect_rejected_unchanged skills/missing check_before="$(digest_tree "$FIX")" HEAL_REPO_ROOT="$FIX" bash "$HEAL" --check skills/sibling >/dev/null [[ "$(digest_tree "$FIX")" == "$check_before" ]] audit_before="$(digest_tree "$REPO_ROOT/skills")" bash "$AUDIT" "$REPO_ROOT/skills/skill-builder" >/dev/null 2>&1 [[ "$(digest_tree "$REPO_ROOT/skills")" == "$audit_before" ]] echo "heal mutation boundaries: PASS" -
validate.sh 1.8 KB
#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" REPO_ROOT="$(cd "$SKILL_DIR/../.." && pwd)" for path in \ SKILL.md \ scripts/build.sh \ scripts/init.sh \ scripts/heal.sh \ scripts/audit.sh \ scripts/score_agentops_skill.py \ schemas/build-report.json \ schemas/audit-report.json \ references/audit-checks.md \ references/codex-parity.md; do [[ -f "$SKILL_DIR/$path" ]] || { echo "skill-builder validate: missing $path" >&2 exit 1 } done for script in scripts/build.sh scripts/init.sh; do [[ -x "$SKILL_DIR/$script" ]] || { echo "skill-builder validate: not executable: $script" >&2 exit 1 } done bash -n "$SKILL_DIR/scripts/heal.sh" "$SKILL_DIR/scripts/audit.sh" bash "$SKILL_DIR/scripts/heal.sh" --check --strict "$SKILL_DIR" before="$(find "$SKILL_DIR" -type f -exec shasum -a 256 {} + | sort | shasum -a 256 | awk '{print $1}')" bash "$SKILL_DIR/scripts/heal.sh" --check "$SKILL_DIR" >/dev/null after="$(find "$SKILL_DIR" -type f -exec shasum -a 256 {} + | sort | shasum -a 256 | awk '{print $1}')" [[ "$before" == "$after" ]] || { echo "skill-builder validate: check mode mutated its target" >&2 exit 1 } if rg -n 'from-pattern|flywheel close-loop|append-skill-disposition' "$SKILL_DIR/SKILL.md" \ || rg -n 'git (status|commit|push)|ao land|retry|queue|lease' \ "$SKILL_DIR/scripts/build.sh" "$SKILL_DIR/scripts/init.sh"; then echo "skill-builder validate: obsolete lifecycle behavior remains" >&2 exit 1 fi if rg -n 'ao land|git (commit|push)|append-skill-disposition|flywheel close-loop' \ "$SKILL_DIR/scripts/heal.sh" "$SKILL_DIR/scripts/audit.sh" \ "$SKILL_DIR/scripts/score_agentops_skill.py"; then echo "skill-builder validate: lifecycle authority remains" >&2 exit 1 fi echo "skill-builder validate: PASS"
-
-
SKILL.md 7.2 KB
--- name: skill-builder description: 'Create, adapt, consolidate or repair skill packages and projections. Use when: authoring guidance, descriptions or structure; Skill Eval measures behavioral benefit.' practices: - pragmatic-programmer - refactoring hexagonal_role: supporting consumes: [] produces: - skill-source-package - skill-hygiene-report - converted-skill - operationalization-proposal context_rel: [] skill_api_version: 1 user-invocable: true context: window: fork intent: mode: questions sections: exclude: - HISTORY metadata: capabilities: [skill_builder, heal_skill, export_skill, distill_expertise] effects: [write_skill_source, write_build_report, regenerate_skill_projections, repair_skill_projections, write_converted_skill_projection, write_advisory_proposal] canonical_status: canonical disposition: keep_specialist tier: meta dependencies: [] stability: experimental output_contract: build-report.json for creation, audit-report.json for audit, target-valid exported files for conversion, or an advisory expertise proposal --- # Skill Builder Create, repair, audit or export one canonical skill package, or turn supported expertise into a small authoring proposal. Search existing owners before adding a root. Extend the owner that already handles the behavior. ## Choose the requested operation | Need | Entry point | |---|---| | Create a source package | `scripts/build.sh` with `from-scratch`, `from-template` or `absorb-external` | | Check package structure | `scripts/heal.sh --check [--strict] skills/<slug>` | | Repair owned projections | `scripts/heal.sh --fix skills/<slug>` | | Audit authoring quality | `scripts/audit.sh [--strict] [--json <path>] skills/<slug>` | | Export to another platform | [Conversion](#conversion) | | Make repeated expertise reusable | [Distill expertise](#distill-expertise) | Run only the selected operation. Skills remain optional tools within the native caller's authorized outcome; this skill does not add execution phases, own work, operate Git, validate a software candidate, or decide delivery and retries. ## Create and maintain Treat external skills as structural signals only. Clean-room output must not copy their names, prose, prompts, scripts or examples. `from-template` reuses metadata defaults; `absorb-external <slug> --from <path>` verifies an input and creates a blank source package. Neither imports another skill's content. For creation, supply one input to `scripts/build.sh`, then replace placeholders with the actual behavior. The caller can supply `SKILL_TIER`, `SKILL_DEPENDENCIES`, `SKILL_CAPABILITIES` and `SKILL_EFFECTS`; lists are JSON arrays. The result is one source package with `SKILL.md` and `scripts/validate.sh`. The builder's report is `.agents/scratch/skill-builder/<slug>-build.json` under [build-report.json](schemas/build-report.json). Edit `skills/<slug>/` as the source owner. Check the completed source with `scripts/heal.sh --check --strict skills/<slug>`, then regenerate its owned projections through the repository's owning commands. `scripts/regen-all.sh` is the integrated projection recipe; `scripts/generate-skill-mesh.py`, `scripts/codex-sync.sh --only <slug>` and `scripts/regen-codex-hashes.sh --only <slug>` are the existing scoped surfaces. Do not repeat work already performed by `build.sh` unless source changes require it. Inspect the generated diff; hand-edit no projection. Check/heal targets must be real direct children of `skills/`; reject missing paths, traversal and symlink spellings. Check mode is read-only. Fix mode regenerates owned projections for explicit targets and does not invent source behavior. Findings name their code, target and concrete issue; `--strict` returns nonzero for findings. Check the slug/name match, description, API version, metadata, live dependencies and linked resources. Deep audit reports structural and advisory authoring findings; it is not a candidate verdict. Its optional JSON follows [audit-report.json](schemas/audit-report.json). Interpret static scores as structure and authoring signals, not proof that a skill works. Exact checks live in [audit checks](references/audit-checks.md), [authoring doctrine](references/authoring-doctrine.md), and [Codex parity](references/codex-parity.md). ## Conversion Use `bash skills/skill-builder/scripts/converter/convert.sh <skill-dir> <target> [output-dir]` for an explicit out-of-tree export. Targets are `codex`, `cursor` and `test`; `--all` selects all source packages, and `--codex-layout inline` selects the legacy inline Codex layout. Read [SkillBundle](references/converter/skill-bundle-schema.md) when format details matter. Parse the source once, render the target, then validate resource parity and target format. Report layout and any omitted Cursor references. The default export is `.agents/projections/converter/<target>/<skill-name>/`. The exporter clean-writes its output directory, so use only the explicit derived target: refuse a source package, its ancestor, or the repository root. Preserve the source unchanged and fix the source or adapter instead of editing output. A parse, write, format or required-resource failure leaves an incomplete export. The shipped `skills-codex/**` remains owned by `scripts/codex-sync.sh` through `scripts/regen-all.sh`; this ad-hoc exporter never replaces that authority. ## Distill expertise When the caller wants a reusable rule, begin with cited occurrences or a named authoritative source. State the trigger, desired behavior, inputs, outputs, negative example and limits. Prefer an addition to an existing reference or skill over a new root, library, gate or workflow; no action is a valid result. An abstraction needs three independently evidenced real occurrences and a successful reapplication to a source case without missing context. Preserve short source excerpts or command results with resolvable citations. Fewer occurrences support a narrow reference note; an authoritative source substitutes only for a faithful statement of that source, not a wider generalization. Use Research's [pattern mode](../research/SKILL.md#pattern-evidence) when the claim needs exemplars and a holdout before packaging. A proposed process artifact must have a concrete consumer, a subject or release decision it informs, an observed defect and a retirement condition. If any is missing, omit the artifact. Code written only to consume it supplies no consumer. Minimal recovery state needs a named evidence-loss or corruption risk. Show a negative/holdout case and how the proposed rule returns the right decision. Return the proposal inline unless a durable proposal was requested. Respect [Memory's source and destination rules](../memory/SKILL.md) for mined material. Evidence cannot publish itself as policy. Build an artifact only when the caller's authorization includes adoption; a proposal-only request ends with the proposal. Repair ordinary known defects within existing authority; tool failures remain explicit facts for the native caller, not an automatic helper chain. For an actual package edit, use the [source template](references/skill-template.md) for required fields and [context density guidance](references/context-density-checks.md) when deciding which prose earns a place. Neither requires adding a new skill.
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.