
Steering Workflow Creator
- 4 installs
- 39 repo stars
- Updated August 4, 2026
- aws-samples/sample-apex-skills
steering-workflow-creator is a skill that authors phased steering workflows for AWS services and pairs each with a matching slash-command shim.
About
steering-workflow-creator is a meta-skill for authoring steering workflows, the phased playbooks an agent reads top to bottom to run a structured AWS engagement like a design review or upgrade. A developer uses it to capture intent, fill a routing table, draft phases with STOP gates, wire tool routing, and lint the result before handoff. It also pairs each workflow with a matching slash-command shim.
- Authors phased steering workflows for any AWS service plus a slash-command shim
- Enforces a convention: frontmatter, header block, five required H2 sections
- Includes a decision matrix for skill vs workflow and a quick_validate linter
Steering Workflow Creator by the numbers
- 4 all-time installs (skills.sh)
- Ranked #580 of 782 Skill Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
steering-workflow-creator capabilities & compatibility
- Capabilities
- workflow authoring · command shim generator · convention linter
- Works with
- aws
- Use cases
- documentation · orchestration
What steering-workflow-creator says it does
A meta-skill for authoring steering workflows — the phased playbooks under `steering/workflows/` that an agent reads top to bottom to conduct a structured engagement
Fill the routing table first, phases second.
npx skills add https://github.com/aws-samples/sample-apex-skills --skill steering-workflow-creatorAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 4 |
|---|---|
| repo stars | ★ 39 |
| Last updated | August 4, 2026 |
| Repository | aws-samples/sample-apex-skills ↗ |
What it does
Author a new phased steering workflow for an AWS service and pair it with a slash-command shim.
Who is it for?
Authors standardizing agent engagement playbooks across AWS services
Skip if: Tweaking an existing workflow or adding a command shim on top of one that already exists
When should I use this skill?
Creating a steering workflow, standardizing steering, or building a phased playbook that plugs into a service hub
What you get
A convention-conforming steering workflow with routing table, STOP gates, and a paired command shim
- a steering workflow file with routing table and STOP gates
- a matching slash-command shim
By the numbers
- five required H2 sections
- ~450-line soft cap
Files
Steering Workflow Creator
A meta-skill for authoring steering workflows — the phased playbooks under steering/workflows/ that an agent reads top to bottom to conduct a structured engagement (design review, upgrade, cost review, and the like). This skill teaches you how to write one. It is not itself a workflow — don't follow it to review a cluster or upgrade anything.
The repo targets every AWS service over time. EKS is the bootstrap domain, not the target. The convention and the routing tree below are deliberately service-agnostic; an EKS worked example lives at @references/examples/eks.md only to show the shape, not to pin the pattern to one service.
When to use this skill
Most tasks that sound like "add something to steering" are actually one of two very different things. Pick the right one before you start — the cost of discovering the answer after you've written 300 lines is high.
Decision matrix — is this a skill or a steering workflow?
| Signal in the user's ask | Build a skill | Build a workflow |
|---|---|---|
| Static domain knowledge, best practices, reference material | yes | no |
| Phased engagement with STOP gates and user checkpoints | no | yes |
| Tied to a specific lifecycle stage (Day 0 / 1 / 2) | no | yes |
| Mutation with an access model and blast-radius contract | no | yes |
| Reusable from many different workflows | yes | no |
| The output is an opinionated report for a human to act on | usually no | yes |
If the ask fits both columns, it's usually a knowledge skill plus a workflow that uses the skill. Write them as two artifacts. Don't smuggle phased engagement into a skill, and don't smuggle static reference content into a workflow — both directions silently lose.
When NOT to use this skill:
- Tweaking an existing workflow — just edit the file. The convention lives in
@references/convention.mdif you need to double-check a rule, andscripts/quick_validate.pylints in isolation. - Adding a command shim on top of a workflow that already exists — copy a sibling in
steering/commands/apex/and go. - Pure slash-command authoring with no workflow behind it — that's a command-shim exercise, not steering.
Capture intent
Before drafting anything, get crisp answers to five questions. If the author can't answer them cold, the workflow probably isn't shaped well enough yet — slow down.
1. Which hub does this workflow plug into? Every workflow has a Part of: hub. For EKS that's steering/eks.md; future services will grow their own hubs. If there is no hub yet, author the hub first — an orphaned workflow is not reachable. 2. Which lifecycle phase — Day 0, Day 1, or Day 2? Day 0 is architect, Day 1 is build, Day 2 is operate. The hub groups workflows by this marker, so pick one and commit. 3. What is the primary knowledge skill this workflow leans on? Name it. If the skill doesn't exist yet, pause and create it first — the workflow will be thin without the brain behind it. 4. What is the access model? read-only, advisory, or mutating (with gates). This is a contract with the user about blast radius, so say it out loud before drafting. 5. What AWS service does this target? The answer drives which example under @references/examples/ applies and which MCP setup-bridge skill to pair with.
One anti-overfitting note: if the author can't name a hub, the workflow may not belong in steering at all. Steering workflows are engagement structure plugged into a service; if there's no service shape, the artifact you want is probably a skill or a generic playbook somewhere else.
Draft the workflow
Phase-two work: get a full draft on disk that satisfies the convention.
Apply @references/convention.md as the non-negotiable spec. It defines the frontmatter keys, the four-line header block, the five required H2 sections in order, the STOP-gate syntax, cross-link style, and the ~450-line soft cap. Treat it as authoritative — where design.md and upgrade.md disagree, the convention picks the stricter of the two, and your new workflow follows the convention, not the drift.
Start from @assets/workflow-skeleton.md. Copy it into steering/workflows/<name>.md and edit in place — do not rewrite from scratch. The skeleton is also the linter's conforming exemplar, so every section is there for a reason.
Fill the routing table first, phases second. It's tempting to start on Phase 1 prose because it's the most tangible part. Resist. The ## How to Route Requests table forces you to enumerate the concrete user intents this workflow handles — four to seven of them — before sinking time into phase content you might throw away. If you can't list four distinct intents with different phase paths, the workflow probably doesn't have enough shape to justify being a workflow; go back to Capture intent.
Once the routing table is solid, draft the phases it points at. Each phase gets a one-paragraph preamble, the required inputs, concrete commands or routing guidance, and an explicit **STOP.** ... gate before any irreversible action or handoff. The STOP marker's exact spelling matters — it's how the linter finds gates and how the agent sees them at a glance.
Wire tool routing
Phase-three work: every phase becomes a routing decision about where its information comes from.
Apply @references/tool-routing.md to each phase. The tree has three sources — knowledge skill, live MCP tools, skill-as-setup-bridge — and three steps at most. For every phase, annotate the source with a single line right under the heading:
Source: knowledge
Source: live
Source: either (framework from <skill>; specifics from live)A phase with no annotation defaults to knowledge, which is the safer misread but still a misread — don't rely on the default, write the annotation.
Every `live` phase needs a named CLI fallback. Not "use the AWS CLI," not "see the docs" — the exact command. The agent invents bad CLI syntax with surprising frequency; naming the command pins the behavior. The fallback lives inline in the phase, not in an appendix.
For every `live` phase, pick the setup-bridge skill for this service. For EKS, that's eks-mcp-server. Future services will grow their own analogous bridges. The bridge is the recovery path when MCP tools aren't in the available-tools list; the CLI fallback is the recovery path when the bridge isn't feasible. Chain them in that order.
EKS-flavored workflows? See @references/examples/eks.md — it shows the Source: annotation, the MCP path, and the CLI fallback for each canonical EKS phase shape. Note that the EKS example is a worked case, not the shape all workflows must take; the rule is the tree in tool-routing.md, and the EKS file is one of many siblings the examples directory will eventually carry.
If you are authoring for a service with no worked example yet — RDS, Lambda, IAM, anything new — add a sibling file under references/examples/<service>.md as part of your work. Use the EKS file as the shape: list the service's discovery skill, its knowledge skill(s), its MCP setup-bridge, and a cheat-sheet of Source: annotations for common phase intents. This is how the pattern library grows; skip it and the next author rediscovers what you learned.
Pair with a command shim
Phase-four work: emit the matching steering/commands/apex/<name>.md slash-command shim. The shim is how users reach the workflow — without it the workflow exists on disk but is effectively unreachable.
Start from @assets/command-shim-skeleton.md. The shim is a thin file: two-key YAML frontmatter plus three tags — <objective>, <execution_context>, <process>. The tag names are fixed; the Claude Code runtime looks for exactly those three. Inventing a fourth tag doesn't break anything, but it adds variance across shims, so don't.
Cross-reference the real shims under steering/commands/apex/ for tone — eks-design.md and eks.md are the current examples. Mirror their action-oriented description style and their one-paragraph <process> block.
One small note: the existing shims in the repo use -- rather than the em-dash — in a few places. That is drift; the convention going forward is em-dash everywhere, in both the workflow and the shim. New shims conform to the convention, not to the drift.
Validate
Phase-five work: before handoff, lint and self-check.
Run the linter:
python scripts/quick_validate.py <path-to-your-draft-workflow.md>It checks the mechanical rules — frontmatter keys, header-block lines in order, required H2 sections in order, STOP-gate spelling, em-dash not double-hyphen, arrow direction, Source: annotations, named CLI fallbacks on live phases, relative cross-links, the ~450-line soft cap. Most of these are one-line fixes.
Then walk the Quality Checklist from the workflow itself — yes, the checklist you just wrote. The point of promoting it from optional to required is that workflows produce recommendations users act on, and without a self-grade pass the agent has no forcing function to catch its own weakly-justified output. Run the checklist mentally against a realistic sample user request; if any item scores poorly, that's a phase to revisit.
If the linter fails and the fix isn't obvious, read @references/anti-patterns.md — most failures map to one of the catalogued drifts observed between design.md and upgrade.md. The linter's error messages quote the anti-pattern entries for exactly this reason.
Iterate
Phase-six work: tighten, not gold-plate.
Common drifts — -- creeping back in, STOP gates written inline rather than bolded, inclusion: manual accidentally copied from a hub file, live phases with hand-waved fallbacks — are catalogued in @references/anti-patterns.md. When a review comment or a linter run turns up a surprise, check the anti-patterns file first; the fix pattern is usually already written.
Keep the workflow under ~450 lines. Past that, the agent's attention gets diluted and the phase gates lose weight. If you are bumping the cap, the workflow is probably two workflows (the routing table will usually show it — two modes that never co-occur) or it is smuggling skill content that should move into the relevant skill's references/ tree.
Anti-overfitting: before declaring done, run the workflow mentally against three or four realistic user phrasings — not the tidy phrasings you had in mind when drafting the routing table, but the casual, partial, slightly-off phrasings a real user would type. If the routing table can't absorb them, rework the table, not the phases. The routing table is the contract with the user; the phases are the implementation.
<objective> Run the APEX <workflow-name> workflow — <one sentence: what running this command accomplishes for the user>. </objective>
<execution_context> @~/.claude/apex-steering/workflows/<workflow-name>.md </execution_context>
<process> Follow the <workflow-name> workflow end-to-end. Detect the user's mode from their message and route into the matching row of the workflow's "How to Route Requests" table. Use the <primary-knowledge-skill> skill for decision frameworks and reference material. Phases: 1) <gather context>, 2) <inspect current state>, 3) <frame the decision>. </process>
<!-- This file is BOTH a starter template authors copy into steering/workflows/<name>.md AND the conforming exemplar the linter validates against. If you modify it, re-run python ../scripts/quick_validate.py <this-file> before committing. Any rule in ../references/convention.md that this file violates becomes a linter error for every future workflow — treat drift here as a repo-wide regression. --> --- name: <workflow-name> description: <Day N {intent} workflow. Front-load the lifecycle phase and the concrete verbs users will type — e.g., "Day 2 cost review workflow. Discovers compute posture, scores hygiene against defaults, emits recommendations."> ---
<Workflow Title>
Part of: \`<Hub Workflow>`\
Lifecycle: Day 0 | Day 1 | Day 2
Skill: <primary-knowledge-skill>
Access Model: read-only | advisory | mutating (with gates)
One paragraph framing: what this workflow accomplishes, what it does not do, and which skill it leans on. Keep it to 3–5 sentences. If the Access Model is read-only or mutating, expand it in a short follow-up section here spelling out CAN / CANNOT — see ./upgrade.md for the pattern. Advisory workflows can skip the expansion unless the boundary is non-obvious.
How to Route Requests
| User intent | Mode / Phase |
|---|---|
| "<Concrete verb the user types, e.g., 'Run a full review'>" | Full run → Phase 1 → 2 → 3 |
| "<Scoped variant, e.g., 'Just check <sub-domain>'>" | Scoped → Phase 1 → 2 (sub-domain only) |
| "<Quick-answer variant, e.g., 'Quick health check'>" | Summary → Phase 1 only, condensed output |
Keep this table to 4–7 rows. If you need more, the extra logic belongs as prose branching inside the phases.
Phases
Phase 1: <Gather context>
Source: knowledge (context framework); discovery via <discovery-skill, e.g., ../../skills/<service>-recon/SKILL.md>
One-paragraph preamble describing what this phase produces. Name the required inputs explicitly so the agent can skip questions already answered by a prior report.
Required inputs:
- <Input 1 — e.g., resource identifier>
- <Input 2 — e.g., region>
- <Input 3 — e.g., environment tier>
Steps:
1. Delegate reconnaissance to the discovery skill. If the user has already run it, read the report and skip to step 3. 2. Confirm the gathered context back to the user in a short summary — do not quiz them on values you already have. 3. Hand off to Phase 2 with the context loaded.
STOP. Confirm the context summary with the user before proceeding to Phase 2.
Phase 2: <Inspect current state>
Source: live
CLI fallback: aws <service> <command> --<identifier> <value> --query '<jmespath>' if MCP unavailable.
One-paragraph preamble describing which aspects of live state this phase inspects and why a wrong answer from cached knowledge would be worse than a failed live call.
MCP path (preferred):
<mcp_tool_name>(<arg>="<value>")
<other_mcp_tool_name>(<arg>="<value>")CLI fallback (named explicitly, one per check):
aws <service> <command-1> --<arg> <value>
aws <service> <command-2> --<arg> <value>If MCP is unavailable, offer the setup-bridge skill once (see ../../skills/<service>-mcp-server/SKILL.md), then — on user decline or environment lockdown — run the CLI fallback and announce reduced confidence per ../references/tool-routing.md.
STOP. Present findings and wait for user acknowledgment before any decision framing.
Phase 3: <Frame the decision>
Source: either (framework from <knowledge-skill>; specifics from the Phase 1 report or live calls)
One-paragraph preamble: the decision framework comes from authored knowledge, the inputs come from this run. Weave them together — don't hand the user a generic trade-off table they have to interpret themselves.
Steps:
1. Load the decision framework from the knowledge skill. 2. Pull the specifics from the Phase 1 report (or live calls if Phase 1 was skipped). 3. Present the trade-off table with this run's values filled in. 4. Run the Quality Checklist before handoff.
STOP. Wait for the user's reaction before suggesting follow-up actions or chaining into a peer workflow (e.g., ./<peer-workflow>.md).
Defaults
| Default | Value | Override when |
|---|---|---|
| <Setting 1 — e.g., review depth> | <Default value — e.g., full> | <Condition — e.g., user specifies a scoped mode> |
| <Setting 2 — e.g., output format> | <Default value — e.g., markdown report> | <Condition — e.g., user asks for JSON> |
| <Setting 3 — e.g., severity scale> | <Default value — e.g., critical / recommended / optional> | <Condition — e.g., existing rubric in play> |
Centralize every "default" the phases reference. If a phase mentions a default, it must appear in this table — or two phases will silently disagree about the value.
Quality Checklist
Self-grade before presenting output to the user. Each item is binary: passes or fails.
- [ ] Every Phase 1 required input is filled in — no placeholders leaked into the output.
- [ ] Each
Source: livefinding is timestamped and cites which tool produced it (MCP or CLI fallback). - [ ] Recommendations cite this run's context — not generic advice copy-pasted from the knowledge skill.
- [ ] Every finding has a concrete next step the user can take; no "consider improving X" hand-waves.
- [ ] If CLI fallback was used, a reduced-confidence notice is attached to the affected findings.
- [ ] The output reads top-to-bottom without requiring the user to cross-reference the workflow file.
Pass threshold: 5/6. Below 4/6 means rework before presenting.
Conversation Style
Be concise. Group related questions into a single turn — never ask one at a time when three belong together. If the user has provided a prior report or existing requirements, read them first and only ask what is missing. Explain routing when activating the workflow so the user knows which mode you picked and why. When a STOP gate fires, name the gate and the concrete thing you need from the user before proceeding — do not stall silently. Use sentence-case headings and em-dashes in prose; arrows → in user-facing decision language, -> only inside code blocks that render in terminals.
Anti-Patterns in Steering Workflows
A catalogue of drift observed in steering/workflows/design.md — the existing workflow in this repo — plus a few more the convention was designed to head off before they appeared. Each entry shows a bad fragment, a good fragment, and one sentence on why.
Authors: read this file when you finish a draft. The linter will quote these entries in its error messages, so fixing them once here is a fix everywhere.
A note on tone: "bad" doesn't mean "the author was wrong." Some of these inconsistencies are artifacts of the workflows being written at different times, for different audiences, before any convention existed. The convention exists now; new drafts should conform to it.
---
1. -- instead of em-dash
Bad:
> **Lifecycle:** Day 2 -- OperateGood (from design.md):
> **Lifecycle:** Day 0 — ArchitectWhy: -- is a typewriter-era workaround. Modern markdown renderers and terminals all handle — (U+2014) cleanly. Pick one and stick to it — the convention picks em-dash because design.md got there first and it's more readable at a glance.
---
2. Missing Access Model in the header block
Bad (from design.md):
> **Part of:** [APEX EKS Hub](../eks.md)
> **Lifecycle:** Day 0 — Architect
> **Skill:** `eks-best-practices`Good:
> Part of: [APEX EKS Hub](../eks.md)
> Lifecycle: Day 0 — Architect
> Skill: eks-best-practices
> Access Model: advisoryWhy: Access Model is a contract with the user about what the agent will and won't do on their behalf. Omitting it makes a read-only review workflow indistinguishable from a mutating execution workflow in the header. The convention promotes it from optional to required.
---
3. Missing ## Defaults section
Bad: no ## Defaults section exists. Default behaviors are scattered throughout phases.
Good (from design.md):
## Defaults
| Setting | Default |
|---------|---------|
| Account model | Single account |
| Compute strategy | Karpenter |
| ...Why: when defaults live inside phases, two phases can silently disagree about what the default is. Centralizing them in a table forces the disagreement to be visible and resolvable.
---
4. Missing ## Quality Checklist section
Bad: no self-grading rubric. The workflow produces plans and reports but never scores them before handoff.
Good (from design.md):
## Quality Checklist
| Dimension | Weight (Full) | Weight (Focused) | What to Check |
|---|---|---|---|
| Security | 20% | Score if in scope | IAM model defined, ... |
| Reliability | 20% | Score if in scope | Multi-AZ topology, PDBs, ... |
...
### Scoring Rules
| Score | Status | Action |
|---|---|---|
| 80-100% | Pass | Present to user |
| 60-79% | Gaps found | Fix identified gaps, re-check |Why: workflows produce recommendations the user acts on. Without a scoring pass, the agent has no forcing function to catch its own weakly-justified output. design.md has one; every workflow ought to.
---
5. Inconsistent STOP-gate syntax
Bad:
STOP on ERROR findings -- present them and ask user to resolve before continuing.Good (convention):
**STOP.** ERROR findings must be resolved before proceeding. Present them to the user and wait for resolution.Why: a consistent bold-STOP-period marker makes gates greppable for the linter and visually unmissable for the agent. Inline STOP mid-sentence blends into surrounding prose. Pick one form (**STOP.** ) and use it everywhere.
---
6. Unicode-vs-ASCII arrow drift inside the same repo
Bad: both forms appearing inside the same repo, e.g. prose using → while ASCII-art diagrams use -> — or worse, the inverse.
Good: pick one per medium. In prose and user-facing tables, use → (U+2192). In ASCII-art diagrams inside code blocks, -> is acceptable because terminal fonts render → inconsistently. The convention pins: prose = →, code-block diagrams = ->.
Why: readers flipping between workflows notice the inconsistency before they notice the content. Pin the choice and move on.
---
7. Phase structure without a Source: annotation
Bad: phases don't declare whether they draw from knowledge, live data, or both. The agent has to infer.
Good (convention):
### Phase 2: Pre-flight Validation
Source: live
Run read-only checks against the cluster. CLI fallback for each check is named inline.Why: the Source: annotation is a half-second for the author and saves the agent from misreading intent. A phase that looks advisory but is actually checking live state will silently use stale knowledge in a bad moment.
---
8. Missing CLI fallback on live phases
Bad: a phase says "use MCP tools to check add-on status" and stops there. When MCP is unavailable, the agent invents a CLI command and often gets it wrong.
Good:
### Check 4: Add-on Compatibility
Source: live
MCP: list_eks_resources(resource_type="addon", cluster_name="<cluster>")
CLI fallback:
aws eks describe-addon-versions --addon-name <name> \
--kubernetes-version <target> --query '...'Why: every live phase has to survive MCP being absent. Named fallbacks keep behavior deterministic; hand-waved fallbacks invite hallucinated commands. Covered in detail in tool-routing.md.
---
9. Duplicated reconnaissance logic inside the workflow
Bad: a workflow describes how to detect Karpenter, how to spot the IaC tool, how to enumerate add-ons — effectively re-implementing eks-recon inline.
Good: the workflow delegates to eks-recon for the detection, reads the resulting YAML, and moves on.
Why: reconnaissance is reusable. Inline detection logic drifts faster than anything else in the repo because it's the part authors copy-paste and tweak. Centralize it in the discovery skill — for EKS that's eks-recon; for future services, an analogous skill.
---
10. inclusion: manual on a workflow file
Bad:
---
name: design
description: ...
inclusion: manual
---Good:
---
name: design
description: ...
---Why: inclusion: manual is for hub files like steering/eks.md that the agent loads only when routing into the service. Workflows are loaded from hub routing tables and command shims — they don't need (and shouldn't carry) the inclusion key. Stripping it keeps the distinction between hub and workflow legible.
Steering Workflow Convention
This is the canonical spec every steering workflow in this repo must satisfy. It distills the latent shape of steering/workflows/design.md into a single, strict contract so new workflows stop drifting.
Treat this file as authoritative. Where the two existing workflows disagree, convention picks the stricter of the two — on the theory that the next author will read this, not the old files. If you find yourself arguing with a rule, read the "why" note attached to it before overriding — most drift is good intentions reinventing an inconsistency.
Workflows that conform to this spec can be linted mechanically (see scripts/quick_validate.py). Anything below that looks optional is still required unless it says "optional" explicitly.
---
Scope of this document
- Applies to: files under
steering/workflows/*.md— the phased playbooks the agent runs against a user request. - Does not apply to: hub files (
steering/<service>.md), command shims (steering/commands/apex/*.md), or skills (skills/*/SKILL.md). Hubs and shims have their own shapes, mentioned here only where they pair with a workflow.
Note on terminology: a "workflow" in this repo is a markdown file that an agent reads top to bottom to conduct a structured engagement. It is not a diagram, not a state machine artifact, not code.
---
Frontmatter
Every workflow file starts with YAML frontmatter containing exactly two keys:
---
name: <workflow-slug>
description: <one-sentence trigger-optimized description>
---Rules:
name— lowercase, hyphenated, matches the filename without extension. This is what command shims and hub tables point at.description— one sentence, front-loaded with the lifecycle phase and the concrete user intents it handles ("Day 2 upgrade workflow. Pre-flight validation, upgrade planning, guided execution with checkpoints, and post-upgrade validation."). This feeds skill/workflow triggering — be specific about the verbs users will type.- Do not include
inclusion:on workflow files.inclusion: manualis reserved for hub files likesteering/eks.mdthat the agent loads only when routing into the service.
Why: workflows are loaded from hub routing tables and command shims, not from auto-inclusion. Adding inclusion: manual to a workflow is redundant at best and silently confusing at worst.
---
Header block
Immediately after the # Title H1, include a blockquote header block with four lines, in this exact order:
> Part of: [<Hub Workflow>](../<hub>.md)
> Lifecycle: Day 0 | Day 1 | Day 2
> Skill: <primary knowledge skill>
> Access Model: read-only | advisory | mutating (with gates)Rules:
Part of:— relative link to the hub this workflow plugs into. Missing hub → the workflow is orphaned, fix that first.Lifecycle:— exactly one ofDay 0,Day 1, orDay 2. You may append a short label after an em-dash (e.g.,Day 0 — Architect,Day 2 — Operate), but the day marker comes first so the hub can group workflows by lifecycle phase.Skill:— the primary knowledge skill the workflow leans on. You may follow it with a|-separated list of frequently used reference files, but don't exceed one line. The rest of the skill tree surfaces naturally as you link into it from later sections.Access Model:— required. One ofread-only,advisory, ormutating (with gates). This is promoted from "nice to have" to mandatory because the existingupgrade.mdcarries an Access Model anddesign.mddoesn't, and the absence cost readers clarity about what the agent is allowed to do.
Why each line exists:
Part ofgrounds the workflow in a service hub so routing is reversible (the hub points to you, you point back to the hub).Lifecycletells the agent which stage-of-ownership context to load and tells humans when this workflow is relevant.Skillnames the ground truth for domain knowledge, so the agent knows where to look before it improvises.Access Modelis a contract between the agent and the user about blast radius. Without it, a workflow for a high-risk operation looks indistinguishable from a workflow for a read-only review.
If the workflow is read-only or mutating, expand the Access Model inline — see upgrade.md for the pattern: a short section right after the header block that spells out CAN / CANNOT and the reasoning. Advisory workflows typically don't need the expansion, but add one if the boundary is non-obvious.
---
Required H2 sections, in order
Every workflow must include these five H2 sections, in this order. Additional H2s (e.g., ## Multi-Hop Upgrades, ## Version Support Awareness) may follow — they do not fit this list but are fine at the end. Do not interleave them among the required five.
1. ## How to Route Requests 2. ## Phases 3. ## Defaults 4. ## Quality Checklist 5. ## Conversation Style
Why this ordering: the agent reads top to bottom. Routing first tells it which mode to pick. Phases tell it what to do. Defaults let it fill gaps without asking. Quality Checklist is the self-grade before handoff. Conversation Style is the always-on tone rule applied across all of the above.
1. How to Route Requests
A markdown table mapping user intent to workflow mode or phase. Columns: User Request | Mode | Phases (or User Request | What to Do, as in design.md). Keep it to 4–7 rows — more than that and the routing logic belongs in prose branching inside the phases.
Why: the agent's first move on receiving a user message is to match intent to a row in this table. Without it, the agent has to infer routing from the phase sequence itself, which it does badly.
2. Phases
Numbered ### Phase N: <Name> sections, each self-contained enough to read independently. Between phases (and inside a phase at a natural checkpoint), use the literal marker **STOP.** ... to mark gates where the agent must wait for user input before continuing.
Each phase should include:
- A one-paragraph preamble describing what this phase accomplishes.
- The required inputs for the phase (often a checklist).
- Concrete commands or routing guidance (see
tool-routing.mdfor the knowledge-vs-live decision). - An explicit STOP gate before any irreversible action or handoff to the next phase.
A Source: knowledge | live | either annotation near the phase heading is strongly recommended — it tells the agent (and the next author) which of the three information sources from tool-routing.md this phase draws from. For Source: live phases, name a CLI fallback so the phase degrades gracefully when MCP tools are unavailable.
Why STOP gates are spelled exactly **STOP.** : consistent phrasing makes them greppable for the linter and visually unmistakable for the agent. Variants like "⚠️ STOP" or "STOP:" invite drift.
3. Defaults
A two-column table of assumed defaults the agent applies unless the user overrides them. Format: | Setting | Default |. This exists so phases can say "present options, default X" and the reader knows exactly what X is.
Why: without a Defaults section, every "default" mention in the phases has to re-specify the value, which invites disagreement between phases. Centralize it.
4. Quality Checklist
A scoring rubric the agent runs before presenting output to the user. At minimum, include:
- Dimensions with weights (a table works well — see
design.md). - Pass/fix/rework thresholds.
- A short "quick self-check" bullet list for the always-true items (e.g., "Latest versions confirmed via internet search").
This is promoted from optional to required because design.md demonstrates it and upgrade.md leaves a gap where one ought to be — readers hitting upgrade.md first have no self-grade rubric to emulate.
Why: workflows produce recommendations the user acts on. Without a self-grade pass, the agent has no forcing function to catch its own weakly-justified outputs. The checklist is the forcing function.
5. Conversation Style
A short bullet list of tone and interaction rules. Examples: "Be concise. Group related questions — don't ask one at a time." / "If given existing requirements, read them first and skip answered questions." / "Explain routing when activating a workflow."
Why last: the style rules apply to everything above, but they're short and don't drive the reader's path through the document. Putting them last keeps the phase content at the top of scrollable real estate.
---
Style rules
These are mechanical. The linter will enforce them.
- Em-dash, not double-hyphen. Use
—(U+2014). Do not use--for parenthetical asides, arrows, or separators.upgrade.mddrifted on this; don't copy it. - Sentence case for H2 headings.
## How to Route Requests, not## HOW TO ROUTE REQUESTSand not## how to route requests. Proper nouns keep their casing (## Karpenter Nuances). - Imperative voice in instructions. "Run the pre-flight check" not "The user should run the pre-flight check" and not "You might run the pre-flight check."
- STOP markers spelled `STOP. ...`. Two asterisks, the word
STOP, a period, two asterisks, a space, then the gate description. No emoji, no alternate punctuation. - Arrows. Prefer
→(U+2192) over->for user-facing decision arrows. In code blocks that will be shown to users,->is acceptable because it renders consistently across fonts. - Line width. No hard wrap. Let markdown renderers reflow. Long tables are fine; long prose lines are fine.
Why all this nitpicking: these are exactly the things two authors will diverge on within a month. Pinning them now means the linter can catch 80% of future drift without human review.
---
Cross-link style
Relative paths only. No absolute filesystem paths, no repo-root-anchored paths, no URLs to the repo's own files.
Patterns:
- To a skill:
../../skills/<skill-name>/SKILL.mdor../../skills/<skill-name>/references/<file>.md. - To a peer workflow:
./<other-workflow>.md. - To the hub:
../<hub>.md. - To an external doc (AWS docs, vendor sites, etc.): full
https://URL. This is the only case where absolute is correct.
Why relative: the repo is meant to be readable on disk, in GitHub, and inside an installed skill. Absolute paths break under at least one of those views. Relative paths survive all three.
---
Command shim pairing
Every workflow ships with a matching steering/commands/apex/<name>.md slash-command shim. The shim is a thin YAML-frontmatter-plus-three-tags file:
---
name: apex:<workflow-name>
description: <one-sentence; mirrors the workflow description, one notch more action-oriented>
---
<objective>
One sentence: what running this command accomplishes.
</objective>
<execution_context>
@~/.claude/apex-steering/workflows/<workflow-name>.md
</execution_context>
<process>
One paragraph: detect intent, route into the workflow, name the skill(s) used.
</process>The shim is not optional. A workflow without a shim cannot be invoked as a slash command, and that is how users reach it. The creator skill emits both files in the same pass for this reason.
Why the three tags are fixed: the Claude Code runtime looks for exactly <objective>, <execution_context>, and <process>. Inventing a fourth tag won't break anything, but it won't help either, and it introduces variance across shims that makes them harder to scan.
---
Length ceiling
Soft cap the workflow file at ~450 lines. upgrade.md is 443 lines. design.md is 354 lines. When a workflow is growing past 450:
- First, move reference content (long checklists, version tables, long procedure blocks) into the relevant skill's
references/tree and link to it. - Second, split into two workflows only if the user intents cleanly separate (e.g., a hypothetical "upgrade-assessment" and "upgrade-execution" — don't do this unless the routing table already shows two distinct modes that never co-occur).
Why ~450: past that, the agent's attention gets diluted and the phase gates lose weight. The limit is soft because some services legitimately need more, but treat crossing it as a review trigger, not a default.
---
Conforming fragment
Minimal skeleton of a workflow that satisfies this spec. Real workflows flesh each section out; this shows the shape.
---
name: cost-review
description: Day 2 cost review workflow. Discovers cluster compute posture, scores cost hygiene against defaults, and emits actionable recommendations.
---
# Cost Review Workflow
> Part of: [APEX EKS Hub](../eks.md)
> Lifecycle: Day 2 — Operate
> Skill: eks-best-practices
> Access Model: read-only
This workflow reviews an EKS cluster's cost posture. It does not modify the cluster. All recommendations are delivered as a report the user can act on.
## How to Route Requests
| User Request | Mode | Phases |
|---|---|---|
| "Review my cluster's cost" | Full review | 1 → 2 → 3 |
| "Am I overpaying for compute?" | Scoped | 1 → 2 (compute only) |
| "Quick cost health check" | Summary | 1 only, condensed output |
## Phases
### Phase 1: Gather Context
Source: either (prefer live when MCP is available; fall back to eks-recon report).
Collect cluster name, region, compute strategy, and workload profile. Use MCP tools if available; otherwise ask the user to run `eks-recon` first.
CLI fallback: `aws eks describe-cluster --name <cluster> --query 'cluster.version'`
**STOP.** Confirm context with the user before proceeding.
### Phase 2: Score Cost Posture
Source: knowledge. Read the `eks-best-practices` cost-optimization reference and score each dimension.
### Phase 3: Deliver Findings
Present the report. Run the Quality Checklist before handing off.
**STOP.** Wait for the user's reaction before suggesting follow-up actions.
## Defaults
| Setting | Default |
|---|---|
| Review depth | Compute + autoscaling |
| Output format | Markdown report with summary table |
| Severity scale | critical / recommended / optional |
## Quality Checklist
| Dimension | Weight | What to Check |
|---|---|---|
| Coverage | 40% | Every requested domain scored |
| Specificity | 30% | Recommendations cite this cluster's context, not generic advice |
| Actionability | 30% | Each finding has a concrete next step |
Pass at 80%. Below 60% means rework before presenting.
## Conversation Style
- Be concise. Group related questions.
- If given a prior `eks-recon` report, read it first and only ask what's missing.
- Explain severity — don't just label a finding "critical", say why.That fragment would pass the linter. Use it as a starting point, then flesh out phases with real commands, real STOP gates, and real references into the relevant skill tree.
Worked Example: EKS Tool Routing
This file shows how the service-agnostic tree in ../tool-routing.md plays out for an EKS-flavored workflow. It is not a full workflow — it's an annotated slice that demonstrates how to pick the discovery skill, the setup-bridge, and the knowledge | live | either annotation on each phase.
When another AWS service is added (RDS, Lambda, IAM, …), drop a sibling file here, do not modify this one. Each service gets its own worked example. The rule itself stays service-agnostic in tool-routing.md.
---
The EKS trio
Three skills in this repo map onto the three information sources:
| Role | Skill | What it is |
|---|---|---|
| Discovery / Phase 1 context | eks-recon | Reads cluster state and emits a structured YAML report. Works against MCP if available, CLI if not. |
| Advisory knowledge | eks-best-practices | Static authored references — decision frameworks, add-on compatibility notes, architecture guidance. |
| Setup-bridge | eks-mcp-server | Walks the user through configuring the EKS MCP Server when it isn't already wired up. |
An EKS workflow author should assume all three exist and route into them. Do not reinvent reconnaissance logic inline, do not duplicate best-practice content, do not write your own MCP setup guide.
---
Phase 1 — Source: knowledge (with live-assisted discovery via eks-recon)
Phase 1 of any EKS workflow gathers cluster context. The recommendation framework for "what to gather" is knowledge; the values themselves come from live tools via eks-recon.
Annotation:
Source: knowledge (context framework); discovery via eks-reconWhat the phase looks like in practice:
- Enumerate the required context items (cluster name, region, version, compute strategy, IaC tool, etc.). This list is authored — it lives in the workflow itself, framed by
eks-recon's module structure. - Delegate the actual gathering to
eks-reconif the user hasn't already run it. Example directive inside the phase: "If you haven't run `eks-recon` yet, run it first — I need the cluster-basics and compute modules. Otherwise, point me at the report." - Read the resulting YAML into context. Skip questions already answered by the report.
Why not just call MCP tools directly? Because reconnaissance is a reusable concern. eks-recon is where the detection patterns live (including how to tell Karpenter from MNG from Auto Mode). Duplicating that logic across every EKS workflow is exactly the drift this skill is built to prevent.
---
Phase 2 — Source: live (with named CLI fallback)
A pre-flight check phase that inspects the deployed cluster for blockers. Version numbers, insight findings, add-on statuses, PDB audits — all ground truth, all live.
Annotation:
Source: liveMCP path (preferred):
# EKS MCP Server tools
get_eks_insights(cluster_name="<cluster>")
list_eks_resources(resource_type="addon", cluster_name="<cluster>")
list_k8s_resources(cluster_name="<cluster>", kind="PodDisruptionBudget", api_version="policy/v1")CLI fallback (named explicitly):
aws eks list-insights --cluster-name <cluster> --filter 'statuses=ERROR,WARNING'
aws eks list-addons --cluster-name <cluster>
kubectl get pdb -A -o jsonWhat happens when MCP is unavailable:
1. The agent detects MCP tools aren't in the available-tools list. 2. It offers the eks-mcp-server skill: "MCP tools aren't available. I can walk you through setting up the EKS MCP Server (roughly five minutes), or I can proceed with CLI calls at reduced confidence. Which do you prefer?" 3. On user approval, hand off to eks-mcp-server → user configures → agent retries MCP. 4. If the user declines setup or the environment blocks it, run the CLI fallback and announce reduced confidence per ../tool-routing.md.
Why named commands, not "use the AWS CLI": the agent invents bad CLI syntax surprisingly often. Naming the exact command pins the behavior.
---
Phase 3 — Source: either
A decision-framing phase that helps the user pick between two paths — say, Karpenter versus managed node groups, or single-tenant versus multi-tenant cluster topology. The framework for the decision (trade-offs, criteria, fit signals) is authored knowledge. The inputs to the decision (current compute strategy, workload count, deployed add-ons) are live.
Annotation:
Source: either (framework from eks-best-practices; cluster specifics from live / Phase 1 report)What the phase looks like in practice:
- Load the relevant
eks-best-practicesreference for the decision framework. - Pull the cluster specifics from the Phase 1
eks-reconreport (or from live MCP calls if Phase 1 was skipped). - Present the trade-off table with this cluster's values filled in — don't give the user a generic trade-off table they have to interpret themselves.
Why `either` is not a cop-out: splitting into two phases would force the user through a pure-reading phase before any customization, which reads as busywork. The either annotation tells the agent "you need both, weave them together," and tells the next author "don't split this without a reason."
---
Cheat sheet for EKS-flavored phases
A quick lookup for the annotation you'd pick for common EKS phase types:
| Phase intent | Source | Notes |
|---|---|---|
| Gather cluster context | knowledge with eks-recon discovery | Framework authored; values live through the recon skill |
| Pre-flight / current-state checks | live | CLI fallback required |
| Pick an architecture option | either | Framework from knowledge; inputs from live |
| Generate a customized recommendation | knowledge | Templates and procedures come from eks-best-practices references |
| Execute mutations | n/a | Workflows in this repo don't mutate directly — they advise. Access Model says why. |
| Post-action validation | live | CLI fallback required |
---
When the user's environment is locked down
Not every user can install MCP tooling. Air-gapped environments, restricted laptops, customers running inside their own VPC with no outbound internet — all real. An EKS workflow should:
1. Detect the constraint early (Phase 1 can ask: "Are you working in an air-gapped or locked-down environment?"). 2. Skip the eks-mcp-server setup offer in that case — recommending a setup the user can't do is noise. 3. Route every live phase directly to the CLI fallback and carry the reduced-confidence notice end to end.
The three-way tree still applies; Step 3 just resolves immediately to CLI rather than to the bridge.
---
Summary
For EKS workflows:
- Phase 1 uses
eks-reconas the discovery skill. livephases prefer the EKS MCP Server, fall back toeks-mcp-serveras the setup-bridge, fall back to named CLI commands.- Advisory content comes from
eks-best-practices. - No phase writes its own reconnaissance logic, best-practice content, or MCP setup guide — all three already exist as skills.
For other AWS services, swap the skill names and the MCP server, keep the structure. Add a sibling file in this directory when you do.
Tool Routing for Steering Workflows
Every phase of a steering workflow is, under the hood, a routing decision: where is the information I need right now coming from? The answer is always one of three sources. This file is the service-agnostic decision tree for picking among them. Once you internalize the tree, phase authoring becomes mechanical — you annotate each phase with its source, name a fallback if the source is live, and move on.
The tree doesn't care whether the service is EKS, RDS, Lambda, IAM, or something that doesn't exist yet. EKS-shaped examples live under examples/eks.md and siblings — do not leak service specifics into the rule itself.
---
The three information sources
Knowledge skill
Static domain knowledge, authored by a human, versioned in git, read from a skill's SKILL.md or references/. The eks-best-practices skill is the current example; every AWS service this repo eventually covers is expected to grow at least one knowledge skill of its own.
Characteristics:
- Deterministic — the same query returns the same text every time.
- Session-independent — doesn't need AWS credentials, kubeconfig, or any live connection.
- Cheap — reading a markdown file is a local operation.
- Opinionated — reflects the author's judgment of what "good" looks like.
- Potentially stale — the author has to keep it current; the file doesn't update itself.
Best for: decision frameworks, best practices, design rationale, "how should I think about X", architectural trade-offs, version-agnostic guidance.
Live MCP tools
Whatever MCP server(s) the target service exposes for reading current state and (sometimes) mutating it. For EKS today that's the EKS MCP Server. Other AWS services will grow their own analogous tools; the routing logic doesn't change when they do.
Characteristics:
- Ground truth — reflects what is actually deployed right now.
- Session-dependent — needs the MCP server wired up and (usually) AWS credentials.
- Potentially failure-prone — network, auth, rate limits all apply.
- Can mutate — some tools are read-only, some are not; the workflow's Access Model dictates which are allowed.
Best for: current state, version numbers in the deployed cluster, inventory, real-time troubleshooting, any step where a wrong answer from cached knowledge would be worse than an error from a failed call.
Skill-as-setup-bridge
A skill whose sole job is to get the user from "MCP is not available" to "MCP is available." The eks-mcp-server skill is the current example — it walks the user through configuring the EKS MCP Server, then hands off. Future services will have analogous bridges (rds-mcp-server, lambda-mcp-server, etc.) as those MCP servers come online.
Characteristics:
- One-shot — once setup succeeds, the skill's job is done for the session.
- User-in-the-loop — the user has to take actions outside the agent (install packages, add config, restart their client).
- Gated by environment — some users can't install arbitrary tooling, and the bridge should surface that early.
Best for: exactly one situation — a phase needs live data, MCP tools aren't available, and setup is plausible in this environment.
---
The decision tree
Apply this tree to every phase. It takes three steps at most.
Step 1: What is this phase asking about?
- Advisory / best-practice / "how should I think about X" → knowledge skill. Cheap, deterministic, session-independent. Prefer this even when MCP is available — MCP tells you what you have, skills tell you what's right. An architecture review, a design rationale, a "should I use Karpenter or MNG" question all belong here.
- Current state / mutation / "what is actually deployed" → live MCP tools. Skill knowledge cannot substitute for ground truth. Node versions, running pods, recent events, insight findings, upgrade history — all live.
- A mix of the two → split the phase. Use the knowledge skill to frame the decision, then use live tools to fill in specifics. Annotate the phase
Source: eitherand spell out which parts come from which.
Step 2: If live is needed, is MCP available?
Check at the start of any live phase:
- MCP tools available → call them directly. No fallback needed for this invocation.
- MCP tools unavailable → go to Step 3.
Step 3: Route to the setup-bridge; if setup isn't feasible, fall back to CLI
- Setup plausible in this environment → route to the setup-bridge skill for this service (for EKS:
eks-mcp-server). Let it walk the user through configuration. Once the user confirms setup is complete, retry Step 2. - Setup not plausible (air-gapped env, user declines, no permissions to install tooling) → fall back to the workflow's documented CLI fallback for this phase. Declare reduced confidence in the output — CLI calls often return less structured data than MCP, and the gap matters for downstream scoring.
- No CLI fallback documented → the workflow is broken for this environment. Treat this as a bug to file, not a condition to handle. Every
livephase MUST document a CLI fallback precisely so this case never happens.
---
What the workflow author must do
Every workflow author has three non-negotiable tasks:
1. Pick or create a discovery skill for Phase 1. Phase 1 is almost always "gather context about the thing we're working on." For EKS, that's eks-recon. For a future RDS workflow, it will be the equivalent rds-recon-style skill — author one if it doesn't exist yet, using eks-recon as the pattern. Do not invent a bespoke discovery pass inside the workflow itself; that knowledge belongs in a skill where other workflows can reuse it.
2. Annotate every phase with its source. A one-line annotation near the ### Phase N: <name> heading:
Source: knowledgeor
Source: liveor
Source: either (prefer live when available; fall back to knowledge)This is for the agent's benefit as much as the reader's — it tells the agent which failure modes to expect and how to degrade. A phase with no annotation defaults to knowledge, which is the safer misread.
3. Document a CLI fallback for every `live` phase. Name the exact command. aws eks describe-cluster --name <cluster> --query 'cluster.version' beats "use the AWS CLI to get the cluster version" every time, because the second one makes the agent guess at syntax and the guess is often wrong. The fallback lives inline in the phase, not in a separate appendix — the agent should never have to hunt for it.
If any of these three tasks feels inconvenient, that's the signal the workflow hasn't thought through its own failure modes yet. Slow down, finish the routing design, then write the phases.
---
Reduced-confidence declarations
When a live phase falls back to CLI (Step 3's last branch), the agent should tell the user what just happened and what it means:
"MCP tools aren't available, and the eks-mcp-server setup isn't feasible right now. I ran the CLI fallback instead. Output is somewhat reduced compared to MCP — I got versions and counts, but not the richer per-resource context MCP would have provided. Downstream recommendations will be appropriately conservative."Why: the user needs to know the output's provenance to decide how much to trust it. A workflow that silently degrades is worse than one that fails loudly — the user has no way to compensate for information they don't know is missing.
---
Anti-routing patterns
A short list of mistakes the tree is designed to prevent:
- Using MCP for advisory content. If the question is "should I use X or Y", MCP has no answer — it only knows what's deployed, not what's good. Route to the knowledge skill.
- Using knowledge for current state. If the question is "what version am I on", knowledge has no answer — it only knows what's good, not what's deployed. Route to live.
- Skipping the setup-bridge on MCP absence. Jumping directly to CLI fallback without offering setup leaves the user worse off permanently. At least mention the bridge once per session.
- Documenting CLI fallbacks as "see the AWS docs". That's not a fallback, that's homework. Name the command.
- Combining sources in a single paragraph without saying so. If the phase mixes knowledge-sourced framing with live-sourced specifics, split the prose visibly so the agent (and reader) can tell which sentence came from where.
---
Service-agnostic summary
The tree as a two-line summary an author can keep in their head:
Advisory? Knowledge. Current-state or mutation? Live, via MCP, with the bridge as MCP's recovery path and a named CLI as the bridge's recovery path.
If a phase doesn't fit that summary, the phase probably wants to be two phases.
#!/usr/bin/env python3
"""Lint a steering-workflow markdown file against the convention.
Usage:
python quick_validate.py <path-to-workflow.md> [--json]
Exit 0 on pass, 1 on any error. Ground truth lives in
`../references/convention.md`, `../references/tool-routing.md`, and
`../references/anti-patterns.md`. The conforming exemplar at
`../assets/workflow-skeleton.md` MUST lint clean.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass, asdict
from typing import List, Optional, Tuple
# ----- data model -----
@dataclass
class Finding:
code: str
line: int # 1-indexed; 0 if file-global
message: str
section: str = ""
anti_pattern: str = ""
def format(self) -> str:
loc = f"line {self.line}" if self.line > 0 else "file"
tail = f" (see references/anti-patterns.md § {self.anti_pattern})" if self.anti_pattern else ""
return f" [{self.code}] {loc}: {self.message}{tail}"
# ----- helpers -----
REQUIRED_SECTIONS = [
"How to Route Requests",
"Phases",
"Defaults",
"Quality Checklist",
"Conversation Style",
]
ACCESS_MODEL_VALUES = {"read-only", "advisory", "mutating"}
# Acronyms tolerated as ALL-CAPS words inside H2 titles under sentence-case rule.
TITLE_ACRONYMS = {"EKS", "MCP", "AWS", "IAM", "CLI", "API", "RDS", "VPC", "DNS", "IaC", "CI", "CD"}
def find_fenced_code_ranges(lines: List[str]) -> List[Tuple[int, int]]:
"""Return 0-indexed inclusive ranges of lines inside ``` fences or HTML comments."""
ranges: List[Tuple[int, int]] = []
in_fence = False
in_html_comment = False
start = -1
fence_re = re.compile(r"^\s*```")
for i, line in enumerate(lines):
if in_html_comment:
if "-->" in line:
ranges.append((start, i))
in_html_comment = False
start = -1
continue
if in_fence:
if fence_re.match(line):
ranges.append((start, i))
in_fence = False
start = -1
continue
if fence_re.match(line):
in_fence = True
start = i
continue
if "<!--" in line and "-->" not in line:
in_html_comment = True
start = i
continue
if "<!--" in line and "-->" in line:
ranges.append((i, i))
if (in_fence or in_html_comment) and start >= 0:
ranges.append((start, len(lines) - 1))
return ranges
def strip_code_spans(line: str) -> str:
"""Remove inline `code` spans (single backtick) from a line for style checks."""
# Remove fenced portions delimited by backticks.
return re.sub(r"`[^`]*`", "", line)
def in_ranges(i: int, ranges: List[Tuple[int, int]]) -> bool:
for a, b in ranges:
if a <= i <= b:
return True
return False
TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$")
def is_table_separator(line: str) -> bool:
return bool(TABLE_SEP_RE.match(line))
# ----- checks -----
def check_frontmatter(lines: List[str]) -> List[Finding]:
findings: List[Finding] = []
# Skip leading HTML comment block (the skeleton uses one) and blank lines.
idx = 0
# Skip HTML comments at the very top.
if idx < len(lines) and lines[idx].lstrip().startswith("<!--"):
while idx < len(lines) and "-->" not in lines[idx]:
idx += 1
idx += 1 # past the closing -->
# Skip blank lines.
while idx < len(lines) and lines[idx].strip() == "":
idx += 1
if idx >= len(lines) or lines[idx].strip() != "---":
findings.append(Finding(
"E001", idx + 1,
"frontmatter missing: file must open with '---'",
))
return findings
start = idx
end = -1
for j in range(idx + 1, len(lines)):
if lines[j].strip() == "---":
end = j
break
if end == -1:
findings.append(Finding(
"E002", start + 1,
"frontmatter not closed: no trailing '---' found",
))
return findings
body = lines[start + 1:end]
has_name = any(re.match(r"^\s*name\s*:\s*\S", ln) for ln in body)
has_desc = any(re.match(r"^\s*description\s*:\s*\S", ln) for ln in body)
has_inclusion = any(re.match(r"^\s*inclusion\s*:", ln) for ln in body)
if not has_name:
findings.append(Finding("E003", start + 1, "frontmatter missing 'name:' key"))
if not has_desc:
findings.append(Finding("E004", start + 1, "frontmatter missing 'description:' key"))
if has_inclusion:
findings.append(Finding(
"E005", start + 1,
"frontmatter must NOT include 'inclusion:' on a workflow file",
anti_pattern="10. `inclusion: manual` on a workflow file",
))
return findings
def locate_title_and_header(lines: List[str]) -> Tuple[int, List[Finding]]:
"""Find H1 title line (0-indexed); return (title_line_idx, findings).
Also validates the 4-line blockquote header block immediately after the title.
"""
findings: List[Finding] = []
# Locate end of frontmatter.
idx = 0
if idx < len(lines) and lines[idx].lstrip().startswith("<!--"):
while idx < len(lines) and "-->" not in lines[idx]:
idx += 1
idx += 1
while idx < len(lines) and lines[idx].strip() == "":
idx += 1
if idx < len(lines) and lines[idx].strip() == "---":
for j in range(idx + 1, len(lines)):
if lines[j].strip() == "---":
idx = j + 1
break
# Skip blank lines after frontmatter.
while idx < len(lines) and lines[idx].strip() == "":
idx += 1
if idx >= len(lines):
findings.append(Finding("E010", 0, "no title line found after frontmatter"))
return -1, findings
title_line = lines[idx]
# First non-frontmatter non-blank line must be '# Title' (exactly one '#').
m = re.match(r"^(#+)\s+", title_line)
if not m:
findings.append(Finding(
"E010", idx + 1,
f"first non-frontmatter line must be '# <Title>', got: {title_line.strip()[:80]!r}",
))
return idx, findings
if len(m.group(1)) != 1:
findings.append(Finding(
"E011", idx + 1,
f"title must be H1 (single '#'), found H{len(m.group(1))}",
))
return idx, findings
# Next non-blank lines -> expect 4-line blockquote header in order.
j = idx + 1
while j < len(lines) and lines[j].strip() == "":
j += 1
header_lines = []
while j < len(lines) and lines[j].lstrip().startswith(">"):
header_lines.append((j, lines[j]))
j += 1
if len(header_lines) >= 8: # sanity cap
break
expected_labels = ["Part of", "Lifecycle", "Skill", "Access Model"]
if not header_lines:
findings.append(Finding(
"E012", idx + 2,
"missing blockquote header block (Part of / Lifecycle / Skill / Access Model)",
anti_pattern="2. Missing `Access Model` in the header block",
))
return idx, findings
# Parse each header line: strip leading '>' and optional bold markers.
parsed: List[Tuple[int, str, str]] = [] # (line_no, label, value)
for ln_idx, raw in header_lines:
body = re.sub(r"^\s*>\s?", "", raw).rstrip("\n")
# Accept **Label:** or Label:
m2 = re.match(r"^\s*\*\*([^*:]+):\*\*\s*(.*)$", body) or re.match(r"^\s*([A-Za-z ]+?):\s*(.*)$", body)
if m2:
parsed.append((ln_idx + 1, m2.group(1).strip(), m2.group(2).strip()))
else:
parsed.append((ln_idx + 1, "", body.strip()))
# Enforce order of the first four expected labels.
for k, expected in enumerate(expected_labels):
if k >= len(parsed):
findings.append(Finding(
"E013", idx + 2,
f"header block missing line {k + 1}: '{expected}:'",
anti_pattern="2. Missing `Access Model` in the header block" if expected == "Access Model" else "",
))
continue
line_no, label, value = parsed[k]
if label.lower() != expected.lower():
findings.append(Finding(
"E014", line_no,
f"header line {k + 1}: expected '{expected}:' but found '{label or '?'}:'",
anti_pattern="2. Missing `Access Model` in the header block" if expected == "Access Model" else "",
))
if expected == "Access Model" and label.lower() == "access model":
# Strip optional parenthetical and skeleton-style value lists.
v = value.lower()
# Accept forms like "read-only", "advisory", "mutating (with gates)",
# or skeleton alternation "read-only | advisory | mutating (with gates)".
head = re.split(r"\s*\||\s*\(", v, maxsplit=1)[0].strip()
if head not in ACCESS_MODEL_VALUES and not any(
v.startswith(x) for x in ACCESS_MODEL_VALUES
):
# Look deeper: any ACCESS_MODEL_VALUES token anywhere?
if not any(tok in v for tok in ACCESS_MODEL_VALUES):
findings.append(Finding(
"E015", line_no,
f"Access Model must be one of {sorted(ACCESS_MODEL_VALUES)}; got {value!r}",
))
return idx, findings
def find_h2_sections(lines: List[str], code_ranges: List[Tuple[int, int]]) -> List[Tuple[int, str]]:
"""Return list of (line_idx_0based, title) for each H2 outside code fences."""
out: List[Tuple[int, str]] = []
for i, ln in enumerate(lines):
if in_ranges(i, code_ranges):
continue
m = re.match(r"^##\s+(.+?)\s*$", ln)
if m and not ln.startswith("###"):
out.append((i, m.group(1).strip()))
return out
def check_required_sections(h2s: List[Tuple[int, str]]) -> List[Finding]:
findings: List[Finding] = []
# Check presence + order + duplicates for the required five.
positions = {name: [] for name in REQUIRED_SECTIONS}
for idx, (lineno, title) in enumerate(h2s):
for req in REQUIRED_SECTIONS:
if title.lower() == req.lower():
positions[req].append((idx, lineno, title))
# Missing?
missing_anti = {
"Defaults": "3. Missing `## Defaults` section",
"Quality Checklist": "4. Missing `## Quality Checklist` section",
}
for req in REQUIRED_SECTIONS:
if not positions[req]:
findings.append(Finding(
"E020", 0,
f"missing required H2 section: '## {req}'",
anti_pattern=missing_anti.get(req, ""),
))
# Duplicate?
for req in REQUIRED_SECTIONS:
if len(positions[req]) > 1:
findings.append(Finding(
"E021", positions[req][1][1] + 1,
f"duplicate required H2 section: '## {req}' appears {len(positions[req])} times",
))
# Case drift note.
for req in REQUIRED_SECTIONS:
for _, lineno, title in positions[req]:
if title != req:
findings.append(Finding(
"E022", lineno + 1,
f"section title case drift: '{title}' should be '{req}'",
))
# Order.
present_order = []
for req in REQUIRED_SECTIONS:
if positions[req]:
present_order.append((req, positions[req][0][0]))
for i in range(1, len(present_order)):
if present_order[i][1] < present_order[i - 1][1]:
findings.append(Finding(
"E023", h2s[present_order[i][1]][0] + 1,
f"section order wrong: '{present_order[i][0]}' appears before '{present_order[i - 1][0]}'",
))
return findings
def check_h2_sentence_case(h2s: List[Tuple[int, str]]) -> List[Finding]:
findings: List[Finding] = []
required_lower = {r.lower() for r in REQUIRED_SECTIONS}
for lineno, title in h2s:
# Required section titles are fixed by spec; don't flag their casing here.
if title.lower() in required_lower:
continue
words = re.findall(r"[A-Za-z][A-Za-z0-9'\-]*", title)
if not words:
continue
capitalized_after_first = 0
for w in words[1:]:
if w in TITLE_ACRONYMS:
continue
if w[0].isupper():
capitalized_after_first += 1
if capitalized_after_first > 1:
findings.append(Finding(
"E024", lineno + 1,
f"H2 title appears title-cased (should be sentence case): '{title}'",
))
return findings
def find_phase_section_range(
lines: List[str], h2s: List[Tuple[int, str]]
) -> Optional[Tuple[int, int]]:
"""Return 0-indexed [start, end) line range for the '## Phases' section."""
start = None
for i, (lineno, title) in enumerate(h2s):
if title.lower() == "phases":
start = lineno
end = len(lines)
if i + 1 < len(h2s):
end = h2s[i + 1][0]
return (start, end)
return None
def check_phases(lines: List[str], h2s: List[Tuple[int, str]], code_ranges: List[Tuple[int, int]]) -> List[Finding]:
findings: List[Finding] = []
rng = find_phase_section_range(lines, h2s)
# Also detect flat ## Phase N: (drift).
# Scan the whole file for H2 phase headings anywhere — that is drift.
for i, ln in enumerate(lines):
if in_ranges(i, code_ranges):
continue
if re.match(r"^##\s+Phase\s+\d+\s*:", ln, re.IGNORECASE):
findings.append(Finding(
"E030", i + 1,
f"phase heading must be H3 '### Phase N: ...', not H2: {ln.strip()[:80]!r}",
))
if rng is None:
return findings
start, end = rng
phase_re = re.compile(r"^###\s+Phase\s+(\d+)\s*:\s*(.+?)\s*$", re.IGNORECASE)
# Collect phase boundaries.
phases: List[Tuple[int, int, str]] = [] # (start_idx, num, name)
for i in range(start, end):
if in_ranges(i, code_ranges):
continue
m = phase_re.match(lines[i])
if m:
phases.append((i, int(m.group(1)), m.group(2)))
# For each phase, slice its body and check Source:, CLI fallback (if live).
for k, (pstart, pnum, pname) in enumerate(phases):
pend = phases[k + 1][0] if k + 1 < len(phases) else end
body = lines[pstart:pend]
# Source annotation.
source_val = None
source_line = -1
for j, bln in enumerate(body):
if in_ranges(pstart + j, code_ranges):
continue
m = re.match(r"^\s*(?:\*\*)?Source(?:\*\*)?\s*:\s*(.+?)\s*$", bln, re.IGNORECASE)
if m:
source_val = m.group(1).strip().lower()
source_line = pstart + j
break
if source_val is None:
findings.append(Finding(
"E031", pstart + 1,
f"phase {pnum} ({pname!r}) missing 'Source:' annotation",
anti_pattern="7. Phase structure without a `Source:` annotation",
))
else:
# Accept tokens knowledge, live, either (possibly followed by more text).
head = re.match(r"(knowledge|live|either)\b", source_val)
if not head:
findings.append(Finding(
"E032", source_line + 1,
f"phase {pnum}: Source value must start with one of knowledge|live|either, got {source_val!r}",
))
else:
if head.group(1) == "live":
# Check CLI fallback presence somewhere in the body.
has_fallback = False
for j, bln in enumerate(body):
if re.match(r"^\s*(?:\*\*)?CLI fallback(?:\*\*)?\s*:", bln, re.IGNORECASE):
has_fallback = True
break
# Prose form: sentence containing 'CLI fallback' and a code span.
if re.search(r"CLI fallback", bln, re.IGNORECASE) and ("`" in bln):
has_fallback = True
break
if not has_fallback:
findings.append(Finding(
"E033", pstart + 1,
f"phase {pnum} is Source: live but has no 'CLI fallback:' line",
anti_pattern="8. Missing CLI fallback on `live` phases",
))
# At least one **STOP.** marker inside the Phases section.
has_stop = False
stop_re = re.compile(r"\*\*STOP\.\*\*")
for i in range(start, end):
if in_ranges(i, code_ranges):
continue
if stop_re.search(lines[i]):
has_stop = True
break
if not has_stop:
findings.append(Finding(
"E034", start + 1,
"Phases section has no '**STOP.**' checkpoint (at least one required)",
anti_pattern="5. Inconsistent STOP-gate syntax",
))
return findings
def check_style_rules(lines: List[str], code_ranges: List[Tuple[int, int]]) -> List[Finding]:
findings: List[Finding] = []
# Skip frontmatter entirely for style checks? No — em-dash drift in frontmatter
# description is still worth flagging. But the header block is already prose.
dash_re = re.compile(r"(?:\w|\s)--(?:\w|\s)")
arrow_re = re.compile(r"->")
for i, ln in enumerate(lines):
if in_ranges(i, code_ranges):
continue
if is_table_separator(ln):
continue
# Skip frontmatter delimiters themselves (---).
stripped = ln.strip()
if stripped == "---":
continue
# Strip inline `code` spans before checking for prose drift.
scrubbed = strip_code_spans(ln)
if dash_re.search(scrubbed):
findings.append(Finding(
"E040", i + 1,
"'--' used as em-dash replacement in prose (use '—' or '-')",
anti_pattern="1. `--` instead of em-dash",
))
if arrow_re.search(scrubbed):
findings.append(Finding(
"E041", i + 1,
"'->' used as arrow in prose (use '→' outside code blocks)",
anti_pattern="6. Unicode-vs-ASCII arrow drift inside the same repo",
))
return findings
def check_length(lines: List[str]) -> List[Finding]:
findings: List[Finding] = []
n = len(lines)
if n > 450:
findings.append(Finding(
"E050", 0,
f"file length {n} lines exceeds hard cap 450 (soft cap ~450; split or move reference content)",
))
elif n >= 400:
findings.append(Finding(
"W051", 0,
f"file length {n} lines approaching 450-line cap",
))
return findings
def section_body(lines: List[str], h2s: List[Tuple[int, str]], name: str) -> Optional[Tuple[int, int]]:
for i, (lineno, title) in enumerate(h2s):
if title.lower() == name.lower():
end = h2s[i + 1][0] if i + 1 < len(h2s) else len(lines)
return (lineno, end)
return None
def count_table_rows(lines: List[str], start: int, end: int, code_ranges: List[Tuple[int, int]]) -> int:
"""Count data rows in markdown tables between [start, end). A data row is a
pipe-bearing line following a separator row."""
rows = 0
in_table = False
after_sep = False
for i in range(start, end):
if in_ranges(i, code_ranges):
in_table = False
after_sep = False
continue
ln = lines[i]
if "|" in ln and ln.strip().startswith("|") or (ln.count("|") >= 2 and not is_table_separator(ln)):
if is_table_separator(ln):
after_sep = True
in_table = True
continue
if after_sep and in_table:
if ln.strip() and ln.count("|") >= 2:
rows += 1
else:
in_table = True
else:
if ln.strip() == "":
# blank line ends a table
in_table = False
after_sep = False
return rows
def check_routing_table(lines: List[str], h2s: List[Tuple[int, str]], code_ranges: List[Tuple[int, int]]) -> List[Finding]:
rng = section_body(lines, h2s, "How to Route Requests")
if rng is None:
return []
rows = count_table_rows(lines, rng[0], rng[1], code_ranges)
if rows < 2:
return [Finding(
"E060", rng[0] + 1,
f"'## How to Route Requests' must contain a table with >=2 data rows; found {rows}",
)]
return []
def check_defaults_table(lines: List[str], h2s: List[Tuple[int, str]], code_ranges: List[Tuple[int, int]]) -> List[Finding]:
rng = section_body(lines, h2s, "Defaults")
if rng is None:
return []
rows = count_table_rows(lines, rng[0], rng[1], code_ranges)
if rows < 1:
return [Finding(
"E061", rng[0] + 1,
f"'## Defaults' must contain a table with >=1 data row; found {rows}",
anti_pattern="3. Missing `## Defaults` section",
)]
return []
def check_quality_checklist(lines: List[str], h2s: List[Tuple[int, str]], code_ranges: List[Tuple[int, int]]) -> List[Finding]:
rng = section_body(lines, h2s, "Quality Checklist")
if rng is None:
return []
start, end = rng
has_checklist = False
has_threshold = False
checklist_re = re.compile(r"^\s*[-*]\s*\[[ xX]\]\s+")
threshold_re = re.compile(r"pass threshold", re.IGNORECASE)
for i in range(start, end):
if in_ranges(i, code_ranges):
continue
if checklist_re.match(lines[i]):
has_checklist = True
if threshold_re.search(lines[i]):
has_threshold = True
out: List[Finding] = []
if not has_checklist:
out.append(Finding(
"E062", start + 1,
"'## Quality Checklist' must contain at least one '- [ ]' or '- [x]' item",
anti_pattern="4. Missing `## Quality Checklist` section",
))
if not has_threshold:
out.append(Finding(
"E063", start + 1,
"'## Quality Checklist' must mention a pass threshold (regex: 'pass threshold')",
anti_pattern="4. Missing `## Quality Checklist` section",
))
return out
# ----- orchestration -----
def run_all_checks(path: str) -> List[Finding]:
with open(path, "r", encoding="utf-8") as f:
text = f.read()
lines = text.splitlines()
code_ranges = find_fenced_code_ranges(lines)
findings: List[Finding] = []
findings.extend(check_frontmatter(lines))
_title_idx, title_findings = locate_title_and_header(lines)
findings.extend(title_findings)
h2s = find_h2_sections(lines, code_ranges)
findings.extend(check_required_sections(h2s))
findings.extend(check_h2_sentence_case(h2s))
findings.extend(check_phases(lines, h2s, code_ranges))
findings.extend(check_style_rules(lines, code_ranges))
findings.extend(check_length(lines))
findings.extend(check_routing_table(lines, h2s, code_ranges))
findings.extend(check_defaults_table(lines, h2s, code_ranges))
findings.extend(check_quality_checklist(lines, h2s, code_ranges))
return findings
def group_findings(findings: List[Finding]) -> dict:
buckets: dict = {}
code_section = {
"E001": "Frontmatter", "E002": "Frontmatter", "E003": "Frontmatter",
"E004": "Frontmatter", "E005": "Frontmatter",
"E010": "Title/Header", "E011": "Title/Header", "E012": "Title/Header",
"E013": "Title/Header", "E014": "Title/Header", "E015": "Title/Header",
"E020": "Required sections", "E021": "Required sections",
"E022": "Required sections", "E023": "Required sections",
"E024": "Required sections",
"E030": "Phases", "E031": "Phases", "E032": "Phases",
"E033": "Phases", "E034": "Phases",
"E040": "Style", "E041": "Style",
"E050": "Length", "W051": "Length",
"E060": "Routing table", "E061": "Defaults table",
"E062": "Quality Checklist", "E063": "Quality Checklist",
}
for f in findings:
section = code_section.get(f.code, "Other")
buckets.setdefault(section, []).append(f)
return buckets
def main(argv: List[str]) -> int:
parser = argparse.ArgumentParser(description="Lint a steering-workflow markdown file.")
parser.add_argument("path", help="path to workflow .md")
parser.add_argument("--json", action="store_true", help="machine-readable output")
args = parser.parse_args(argv)
try:
findings = run_all_checks(args.path)
except FileNotFoundError:
print(f"ERROR: file not found: {args.path}", file=sys.stderr)
return 1
errors = [f for f in findings if f.code.startswith("E")]
warnings = [f for f in findings if f.code.startswith("W")]
if args.json:
out = {
"path": args.path,
"pass": len(errors) == 0,
"findings": [asdict(f) for f in findings],
}
print(json.dumps(out, indent=2))
return 0 if not errors else 1
print(f"quick_validate: {args.path}")
if not findings:
print(" (no findings)")
else:
buckets = group_findings(findings)
for section in [
"Frontmatter", "Title/Header", "Required sections",
"Phases", "Style", "Routing table", "Defaults table",
"Quality Checklist", "Length", "Other",
]:
items = buckets.get(section)
if not items:
continue
print(f"\n{section}:")
for f in items:
print(f.format())
print()
if errors:
print(f"FAIL — {len(errors)} error(s), {len(warnings)} warning(s)")
return 1
else:
print(f"PASS — 0 error(s), {len(warnings)} warning(s)")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
Related skills
FAQ
When should I build a skill instead of a workflow?
Static domain knowledge, best practices, or reusable reference content should be a skill; phased engagements with STOP gates and checkpoints should be a workflow.
Do I fill phases or the routing table first?
Fill the routing table first to enumerate four to seven concrete user intents, then draft the phases it points at.