skf-campaign
Campaign orchestration — multi-library skill production with dependency tracking, file-based state, and resume. Use when the user asks to "run a campaign" or "orchestrate skills."
Install
npx skills add https://github.com/armelhbobdad/bmad-module-skill-forge/tree/main/src/skf-campaign
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install armelhbobdad-bmad-module-skill-forge@llmmart
git clone https://github.com/armelhbobdad/bmad-module-skill-forge.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole armelhbobdad/bmad-module-skill-forge collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
Campaign
Overview
Orchestrates the production of 15+ skills across multiple sessions by driving them through the full SKF pipeline (brief, generate, compile, test, export) in dependency order. Campaign sits atop the pipeline ladder: it sequences the workflows that produce skills rather than producing artifacts itself. File-based state (_campaign-state.yaml) survives context death, enabling resume from any point.
Conventions
- Bare paths (e.g.
references/step-01-setup.md) resolve from the skill root. references/holds the stage-chained step files plus reference specs (e.g. the_campaign-directive.mdcontract atreferences/campaign-directive-spec.md);templates/,scripts/, andassets/hold templates, deterministic helpers, and the state schema.{skill-root}resolves to this skill's installed directory (wherecustomize.tomllives).{project-root}-prefixed paths resolve from the project working directory.{skill-name}resolves to the skill directory's basename.
Role
You are a campaign orchestrator operating in Ferris's Management mode. You sequence workflows, track per-skill state, enforce quality gates, and ensure every skill reaches its target tier — while the individual pipeline workflows handle the actual artifact production.
On Activation
Run these steps once, in order, before dispatching to Mode Routing.
Load config. Read
{project-root}/_bmad/skf/config.yamland{sidecar_path}/preferences.yamlin one batched message (independent files). From config resolveproject_name,user_name,communication_language,document_output_language,skills_output_folder,forge_data_folder,sidecar_path. From preferences resolveheadless_mode(default false). If the config file is missing, fall back toforge_data_folder = forge-data.Resolve
{headless_mode}— true if--headlessor-Hwas passed as an argument, or ifheadless_mode: trueinpreferences.yaml. Default: false.Resolve workflow customization. Run:
python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflowThe script merges three layers (scalars override, arrays append):
{skill-root}/customize.toml— bundled defaults_bmad/custom/<skill-name>.tomlunder{project-root}— team overrides (committed)_bmad/custom/<skill-name>.user.tomlunder{project-root}— personal overrides (gitignored)
If it fails or is missing, fall back to
{skill-root}/customize.tomldirectly. Resolve each scalar now (so step files never repeat the conditional) and stash as workflow-context variables:{campaignWorkspacePath}←workflow.campaign_workspace_pathif non-empty, else{forge_data_folder}/_campaign{qualityGateHard}/{qualityGateSoftTarget}/{qualityGateSoftFallback}← thequality_gate_*scalars (defaultszero-critical-high/90/80){reportTemplatePath}←workflow.report_template_pathif non-empty, elsetemplates/campaign-report-template.md{kickoffTemplatePath}←workflow.kickoff_template_pathif non-empty, elsetemplates/kickoff-template.md{briefTemplatePath}←workflow.brief_template_pathif non-empty, elsetemplates/campaign-brief-template.yaml{onComplete}←workflow.on_complete(empty = no-op)
Load
workflow.persistent_facts(literal sentences andfile:references, globs expanded) and keep them in mind for the whole campaign — they are injected into every per-skill kickoff. Run anyactivation_steps_prependbefore step 1 and anyactivation_steps_appendafter this step.Parse CLI overrides into the workflow context:
Flag Effect --headless/-HForce {headless_mode} = true(see step 2).--brief <file>Seed step-01 targets from a campaign-brief.yamlinstead of interactive prompts. Implies--headless.--manifest <file>Seed step-01 targets from a plain-text name,repo_url,tier,pinmanifest. Implies--headless.--from <skill>Resume override — see Mode Routing. If
--briefor--manifestis set, force{headless_mode} = true(log "headless: coerced by --brief/--manifest" if it was false).--manifestformat: onename,repo_url,tier,pintarget per line (emptypin= latest); a trailing;dep1,dep2segment setsdepends_on; blank and#lines are skipped. A malformed line HALTs step-01 with the offending line numbers — never a partial target set.Dispatch per Mode Routing below.
Workflow Rules
These rules apply to every step in this workflow:
- State-first — write state to disk before chaining to the next step or workflow
- Read-backup-modify-write for all state mutations (State Contract in
references/campaign-contracts.md) - Validate
_campaign-state.yamlon every load by runninguv run scripts/campaign-validate-state.py --state-file {stateFile}and HALT (exit code 3,invalid-state) on non-zero — never hand-validate the schema - Zero memory dependency — campaign state is 100% recoverable from disk; never rely on conversation context for progress tracking
- Treat a missing or unparseable
SKF_*_RESULT_JSONenvelope from any sub-skill as a sub-skill failure; never write partial state from an unparsed envelope - Append a one-line entry to the campaign decision log (
{campaignWorkspacePath}/_campaign-decision-log.md, append-only) at every operator or auto-decision (skip/force, overwrite, export cancel/proceed,.bakrecovery, user-cancel) so rationale survives compaction and resume - Universal cancel affordance — at any interactive gate between Setup and the Export gate,
cancel/exit/:qtriggers a HARD HALT with exit code 12 (user-cancelled): log it and leave state intact and resumable. Exception: the Export gate's own[C]ancelstays exit code 11 (export-cancelled) — never also emit 12 there, so an automator's exit-code branch stays deterministic. These keywords count only as a response to a prompt; a skill or campaign namedcancel/exitsupplied as data is never treated as a cancel. - Always communicate in
{communication_language} - If
{headless_mode}is true, auto-proceed through confirmation gates with their default action and log each auto-decision - If
{headless_mode}is true, emit a single-line JSON progress event to stderr at each step's entry, exit, and HARD HALT so schedulers stream live progress — event format inreferences/campaign-contracts.md(Headless Progress Events)
Stages
| # | Step | File | Auto-proceed |
|---|---|---|---|
| 0 | Setup | references/step-01-setup.md | Yes |
| 1 | Strategy | references/step-02-strategy.md | Yes |
| 2 | Pin Validation | references/step-03-pins.md | Yes |
| 3 | Provenance | references/step-04-provenance.md | Yes |
| 4 | Skill Loop | references/step-05-skill-loop.md | Yes |
| 5 | Tier B Batch | references/step-06-batch.md | Yes |
| 6 | Capstone | references/step-07-capstone.md | Yes |
| 7 | Verification | references/step-08-verify.md | Yes |
| 8 | Refinement | references/step-09-refine.md | Yes |
| 9 | Export | references/step-10-export.md | No (write-gate HALT) |
| 10 | Maintenance | references/step-11-maintenance.md | Yes |
Stage numbering: step files are 1-indexed (step-01 … step-11); campaign.current_stage in state is 0-indexed, so step-NN runs stage NN − 1 (step-01 = stage 0, step-11 = stage 10). references/step-resume.md §3–§4 own how a resolved stage maps back to its step file on resume (including the current_stage + 1 advance when no skill is active).
Invocation Contract
| Aspect | Detail |
|---|---|
| Inputs | campaign to start a new campaign; campaign resume [--from=<skill>] to resume from last active or specified skill; campaign status for a read-only progress summary |
| Outputs | _campaign-state.yaml (state), campaign-brief.yaml (machine-generated brief), campaign-report.md (post-campaign summary), _campaign-decision-log.md (append-only rationale), SKF_CAMPAIGN_RESULT_JSON (headless envelope) — all under {campaignWorkspacePath} |
Contracts
Exit codes, the HARD-HALT error envelope, the read-backup-modify-write State Contract, the headless success envelope, and per-step progress events live in references/campaign-contracts.md — consult it when you HALT, mutate state, or emit headless output.
Mode Routing
On invocation:
campaign resume [--from=<skill>]— loadreferences/step-resume.md(validates state, recovers from backup, chains to the right stage).--from=<skill>overrides the resume point to the named skill.campaign(new, no existing state) — run from stage 0 (Setup).campaign(state exists) — detect existing{campaignWorkspacePath}/_campaign-state.yamland prompt resume (viareferences/step-resume.md) or overwrite. On overwrite, first archive the existing_campaign-state.yamlandcampaign-brief.yamlto{campaignWorkspacePath}/archive/{name}-{timestamp}/and log it before chaining to step-01. In headless mode, default to resume (never silently clobber); archive-and-overwrite only when--brief/--manifestexplicitly seeds a new campaign.campaign status(read-only) — load{campaignWorkspacePath}/_campaign-state.yaml, validate it viacampaign-validate-state.py, then runuv run scripts/campaign-status.py --state-file {campaignWorkspacePath}/_campaign-state.yamland display its summary (campaign name, current stage, completed-vs-total, per-status counts) followed by the last ~15 lines of{campaignWorkspacePath}/_campaign-decision-log.mdfor the recent decision trail, then stop. No backup, no mutation, no chaining. Exit 0 (or 9 if the state is unrecoverable).
Files (bmad-module-skill-forge)
-
assets
-
campaign-state-schema.json 5.5 KB
{ "$schema": "http://json-schema.org/draft-07/schema#", "title": "campaign-state", "description": "Schema for _campaign-state.yaml — single source of truth for campaign orchestration state", "type": "object", "required": ["campaign", "skills", "dependency_graph"], "additionalProperties": false, "properties": { "campaign": { "type": "object", "required": ["name", "started_at", "last_updated", "current_stage", "quality_gate", "health_findings_queue"], "additionalProperties": false, "properties": { "name": { "type": "string" }, "started_at": { "type": "string", "format": "date-time" }, "last_updated": { "type": "string", "format": "date-time" }, "current_stage": { "type": "integer", "minimum": 0, "maximum": 10 }, "directive_path": { "type": "string" }, "quality_gate": { "type": "object", "required": ["hard", "soft_target", "soft_fallback"], "additionalProperties": false, "properties": { "hard": { "type": "string" }, "soft_target": { "type": "number" }, "soft_fallback": { "type": "number" } } }, "health_findings_queue": { "type": "string", "enum": ["local", "improvement"] }, "architecture_doc_path": { "type": ["string", "null"], "description": "Path to the architecture document consumed by the verify (Stage 7) and refine (Stage 8) stages. Sourced at setup; null when the campaign runs no verification." }, "capstone": { "type": ["object", "null"], "additionalProperties": false, "description": "Capstone (stack) outcome composed from all completed campaign skills (Tier A and Tier B). Campaign-level summary; the composed skill itself lives at skill_path.", "properties": { "skill_path": { "type": ["string", "null"] }, "quality_score": { "type": ["number", "null"] }, "verified": { "type": ["boolean", "null"] }, "completed_at": { "type": ["string", "null"], "format": "date-time" } } }, "verification": { "type": ["object", "null"], "additionalProperties": false, "description": "Stage 7 verify-stack summary. Detailed findings live in the external report at report_path.", "properties": { "report_path": { "type": ["string", "null"] }, "overall_verdict": { "type": ["string", "null"], "enum": ["Verified", "Plausible", "Risky", "Blocked", null] }, "coverage_percentage": { "type": ["number", "null"] }, "recommendation_count": { "type": ["integer", "null"] } } }, "refinement": { "type": ["object", "null"], "additionalProperties": false, "description": "Stage 8 refine-architecture summary. The refined document lives at refined_path.", "properties": { "refined_path": { "type": ["string", "null"] }, "gap_count": { "type": ["integer", "null"] }, "issue_count": { "type": ["integer", "null"] }, "improvement_count": { "type": ["integer", "null"] } } } } }, "skills": { "type": "array", "items": { "type": "object", "required": ["name", "status", "tier"], "additionalProperties": false, "properties": { "name": { "type": "string" }, "status": { "type": "string", "enum": ["pending", "active", "completed", "failed", "skipped"] }, "depends_on": { "type": "array", "items": { "type": "string" } }, "tier": { "type": "string", "enum": ["A", "B"] }, "pin": { "type": ["string", "null"] }, "brief_path": { "type": ["string", "null"] }, "skill_path": { "type": ["string", "null"] }, "quality_score": { "type": ["number", "null"] }, "workarounds_applied": { "type": "array", "items": { "type": "string" } }, "started_at": { "type": ["string", "null"], "format": "date-time" }, "completed_at": { "type": ["string", "null"], "format": "date-time" }, "commit_sha": { "type": ["string", "null"] } } } }, "dependency_graph": { "type": "object", "required": ["execution_order", "circular_deps_detected"], "additionalProperties": false, "properties": { "execution_order": { "type": "array", "items": { "type": "string" } }, "circular_deps_detected": { "type": "boolean" } } } } }
-
-
references
-
campaign-contracts.md 5.1 KB
<!-- Config: communicate in {communication_language}. --> # Campaign Contracts The self-contained contracts consulted at a specific moment — when a step HALTs, mutates state, or emits the final headless envelope. Each stands alone so it survives context compaction. ## Exit Codes Every HARD HALT exits with a stable, documented code so headless automators can branch on the failure class without grepping message text: | Code | Meaning | Raised by | | ---- | -------------------- | ----------------------------------------------------------- | | 0 | success | step-11 (terminal); step-resume §3–§4 (campaign already complete — nothing to resume) | | 2 | invalid-input | step-01 §1 (no targets, or a malformed `--manifest` line); steps 02/03/04/06 §4 (a helper reports unreadable input or a required tool such as `gh` unavailable); step-resume §1/§3 (resume targets a missing campaign or unknown skill) | | 3 | invalid-state | any step §1 (`campaign-validate-state.py` non-zero on load) | | 4 | circular-deps | step-02 §5 (a dependency cycle, or a dangling `depends_on` reference — either way the graph cannot be ordered) | | 5 | invalid-pin | step-03 §5 | | 6 | inaccessible-repo | step-04 §5 | | 7 | dependency-deadlock | step-05 §4 (no skill ready and no recovery chosen) | | 8 | missing-brief | step-03/04/05 §2 and step-06 §4 (brief missing/unreadable, or a Tier B target has no matching brief entry) | | 9 | corrupt-state | step-resume §1 (primary unrecoverable, `.bak` also invalid) | | 10 | report-failure | step-11 §2 — **degraded only**: the report could not be generated; the campaign still completes and state stays intact (never a hard halt that discards a finished campaign) | | 11 | export-cancelled | step-10 §4 (operator chose `[C]ancel` — graceful, resumable) | | 12 | user-cancelled | any interactive gate (operator typed `cancel` / `exit` / `:q` at a prompt between Setup and the Export gate — graceful, resumable) | ## Result Contract on HARD HALT In addition to the success-variant envelope (see Campaign Headless Envelope below), every HARD HALT emits an **error variant** so automators don't silently break. Emit one line on **stderr**: ``` SKF_CAMPAIGN_RESULT_JSON: {"status":"error","exit_code":<N>,"phase":"<slug>","error":{"code":"<class>","message":"<short>"},"skills_completed":N,"skills_failed":N,"campaign_report_path":null,"decision_log":"<path-or-null>"} ``` `<class>` is the Exit Codes meaning (e.g. `circular-deps`, `inaccessible-repo`); `<slug>` is the step where the HALT occurred. One line, no pretty-print. ## State Contract All state mutations follow the read-backup-modify-write pattern: 1. **Read** `_campaign-state.yaml` 2. **Validate** via `uv run scripts/campaign-validate-state.py --state-file {stateFile}` (halt on invalid) 3. **Backup** — copy current `_campaign-state.yaml` to `_campaign-state.yaml.bak` 4. **Modify** in memory 5. **Update** `campaign.last_updated` to current ISO-8601 timestamp 6. **Write** modified state back to `_campaign-state.yaml` The `.bak` file is one-deep (overwritten on every write). If the primary file is corrupted (crash during write), the `.bak` file contains the last valid state — step-resume §1 recovers from it automatically rather than dead-halting. ## Campaign Headless Envelope When `{headless_mode}` is true, the final step emits a single-line JSON envelope on stdout: ``` SKF_CAMPAIGN_RESULT_JSON: {"status":"success|error","skills_completed":0,"skills_failed":0,"quality_scores":{},"campaign_report_path":"","decision_log":"","duration":""} ``` `status` is `"success"` when the campaign completes normally, `"error"` on any unrecoverable halt (with `exit_code` per the Exit Codes table — see Result Contract on HARD HALT above). `skills_completed` and `skills_failed` count per-skill outcomes. `quality_scores` maps skill names to their test-skill scores. `campaign_report_path` points to the generated `campaign-report.md`. `decision_log` points to `_campaign-decision-log.md`. `duration` is the wall-clock time of the campaign run. Populate the counts, `quality_scores`, and `duration` directly from the `campaign-report.py` result JSON (step-11 §2) — do not recompute them by hand. ## Headless Progress Events When `{headless_mode}` is true, emit a single-line JSON progress event to **stderr** at each step's entry, exit, and HARD HALT, so schedulers stream live progress instead of post-mortem-parsing the final envelope: - entry: `{"stage":N,"name":"<slug>","status":"start"}` - exit (just before chaining): `{"stage":N,"name":"<slug>","status":"done"}` - on HARD HALT: `{"stage":N,"name":"<slug>","status":"halt","exit":<code>}` instead of `"done"` `N` is the 0-indexed stage number (0–10) and `<slug>` is the kebab portion of the step filename. For the non-numbered routing/terminal steps (`resume`, `health-check`) emit `"stage":null` with the slug. One line per event; do not pretty-print. -
campaign-directive-spec.md 1.5 KB
--- stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' directiveFile: '_campaign-directive.md' --- # Campaign Directive Specification Canonical contract for the campaign directive (`_campaign-directive.md`) — a file-based standing directive holding campaign-wide policy. When a step loads `campaign.directive_path` it re-reads the file fresh from disk at stage entry (no caching, so operator edits between stages are picked up) and applies the sections below as campaign-wide context. UTF-8 markdown; frontmatter not required; default filename `_campaign-directive.md`. ## Recognized Sections All optional — the directive may hold any combination of these, or none: - **`## Quality Overrides`** — operator adjustments to quality gates for specific skills or the whole campaign. - **`## Skip List`** — skills to skip during processing, with rationale. - **`## Pipeline Flags`** — per-skill or campaign-wide pipeline modifiers. - **`## Notes`** — free-form operator context for the agent processing the campaign. Any heading not listed above is treated as general guidance: read it and apply judgment based on the content. This lets operators add ad-hoc context without modifying this specification. ## Absence Behavior - If `campaign.directive_path` is not set in the state file: no error, proceed with defaults - If `campaign.directive_path` is set but the file does not exist at that path: no error, proceed with defaults - The directive is always optional — a campaign runs identically without one -
health-check.md 1.7 KB
--- # Note: `shared/health-check.md` resolves relative to the SKF module root # ({project-root}/_bmad/skf/ when installed, {project-root}/src/ during # development), NOT relative to this step file. nextStepFile: 'shared/health-check.md' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' --- <!-- Config: communicate in {communication_language}. --> # Workflow Health Check ## STEP GOAL: Apply the operator's findings-routing consent, then chain to the shared workflow self-improvement health check at `{nextStepFile}`. This is the terminal step of skf-campaign — after the shared health check completes, the workflow is fully done. ## Rules - No user-facing reports, file writes, or result contracts in this step — those belong in step 11. - The ONLY processing permitted here is reading the routing preference (below) and carrying it into `{nextStepFile}`; otherwise delegate directly with no additional commentary. ## MANDATORY SEQUENCE ### 1. Apply findings-routing consent Read `campaign.health_findings_queue` from `{stateFile}` and carry it into the shared health check as the operator's **already-decided** routing consent (so the shared step does not re-prompt, satisfying its "explicit opt-in" requirement for non-bug findings): - `"improvement"` — the operator opted in at setup: route this campaign's friction/gap findings to the shared improvement queue (the shared step's opt-in is pre-satisfied; do not re-prompt). - `"local"` (default) — keep findings in the local queue only; do not submit to the shared queue. If the state file is unreadable, default to `"local"` (never submit without consent). ### 2. Delegate Load `{nextStepFile}`, read it fully, then execute it — applying the routing consent from step 1. -
step-01-setup.md 5.9 KB
--- nextStepFile: 'step-02-strategy.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' briefFile: '{campaignWorkspacePath}/campaign-brief.yaml' templateFile: '{briefTemplatePath}' validateScript: 'scripts/campaign-validate-state.py' manifestScript: 'scripts/campaign-parse-manifest.py' --- <!-- Config: communicate in {communication_language}. --> # Setup ## STEP GOAL: Collect campaign inputs from the operator, create the initial `_campaign-state.yaml`, and generate `campaign-brief.yaml` so the campaign has a persistent starting point that survives context death. This is the only step that creates the state file (it does not yet exist). All subsequent steps use **read-backup-modify-write** per the State Contract in `references/campaign-contracts.md`. ## RULES - This step creates the state file — there is no existing state to read or back up. - Validate the written state with `uv run {validateScript} --state-file {stateFile}` before generating the brief. HALT (exit code 3, `invalid-state`) on non-zero, surfacing the script's `errors[]`. - If `{headless_mode}` is true, draw inputs from `--brief`/`--manifest` (On Activation step 4) and auto-proceed through confirmation gates with the default action, logging each auto-decision to the decision log. ## TASKS ### §1 — Collect Inputs Accept from the operator (or, in headless mode, from the `--brief`/`--manifest` source parsed in On Activation): - `campaign_name` — string identifier for this campaign run - Target libraries — each entry requires: - `name` — skill name - `repo_url` — source repository URL - `tier` — `"A"` (full pipeline) or `"B"` (batch) - `pin` — version pin (string) or `null` for latest - `depends_on` — array of skill names this target depends on (may be empty) - `directive_path` (optional) — path to a `_campaign-directive.md` file with operator directives (contract: `references/campaign-directive-spec.md`) - `architecture_doc_path` (optional) — path to the architecture document the verify (Stage 7) and refine (Stage 8) stages consume. If omitted here, those stages discover it at runtime (`docs/architecture.md`, then `_bmad-output/planning-artifacts/architecture.md`). Capturing it now persists the choice across resume and avoids re-prompting. When seeding from `--manifest`, parse it deterministically: `uv run {manifestScript} <manifest-file>`. If the result's `errors[]` is non-empty (script exit 1), HALT (exit code 2, `invalid-input`) listing the offending line numbers — never run a partial target set. When seeding from `--brief`, read the existing `campaign-brief.yaml` directly. If no targets can be collected (empty interactive input or empty `--brief`/`--manifest`), HALT (exit code 2, `invalid-input`) with guidance — a campaign needs at least one target. ### §2 — Health Queue Preference Default to `"local"` (project-local findings queue). Present the opt-in prompt: > Send anonymized quality findings to the shared improvement queue? [y/N] - **y** — set `health_findings_queue` to `"improvement"` - **N** (default) — keep `health_findings_queue` as `"local"` In headless mode: auto-select `"local"` (N) and log the auto-decision. ### §3 — Build State Object Construct `_campaign-state.yaml` in memory from collected inputs. Use the campaign-wide quality gate resolved in On Activation: `{qualityGateHard}` / `{qualityGateSoftTarget}` / `{qualityGateSoftFallback}`. Note: `repo_url` (collected in §1) is NOT part of the state schema — it belongs in the brief only (§5). The state schema enforces `additionalProperties: false`, so including it would fail validation. ```yaml campaign: name: "{campaign_name}" started_at: "{current_iso8601_with_tz}" last_updated: "{current_iso8601_with_tz}" current_stage: 0 directive_path: "{directive_path or omit if not provided}" architecture_doc_path: "{architecture_doc_path or omit if not provided}" quality_gate: hard: "{qualityGateHard}" soft_target: {qualityGateSoftTarget} soft_fallback: {qualityGateSoftFallback} health_findings_queue: "{local or improvement}" skills: # One entry per target: - name: "{target.name}" status: "pending" depends_on: [] # from target.depends_on tier: "{target.tier}" pin: null # from target.pin brief_path: null # populated in step-05 once BS produces the skill's brief skill_path: null quality_score: null workarounds_applied: [] started_at: null completed_at: null dependency_graph: execution_order: [] # populated by step-02-strategy circular_deps_detected: false ``` ### §4 — Write + Validate State 1. Ensure the directory `{campaignWorkspacePath}/` exists (create if missing). 2. Write the constructed state to `{stateFile}`. This is the initial creation — no `.bak` is needed for the first write; all subsequent steps use read-backup-modify-write. 3. Run `uv run {validateScript} --state-file {stateFile}`. On non-zero (invalid), **HALT** (exit 3) with the script's `errors[]` — do not proceed to brief generation with an invalid state file. ### §5 — Generate Brief Populate `{templateFile}` with collected inputs and write to `{briefFile}`. Fill in: - `campaign_name` — from collected input - `created_at` — current ISO-8601 timestamp with timezone - `targets` — array of target entries with `name`, `repo_url`, `tier`, `pin`, `depends_on` - `quality_gate` — `{qualityGateHard}` / `{qualityGateSoftTarget}` / `{qualityGateSoftFallback}` - `health_findings_queue` — from the §2 preference decision - `architecture_doc_path` — from collected input, or empty string if not provided - `notes` — operator-provided context, or empty string The brief is a machine-readable snapshot enabling fresh-context resume. ## OUTPUT Confirm state file creation and brief generation. Display summary: - Campaign name - Number of targets - Health queue setting Chain to `{nextStepFile}`. -
step-02-strategy.md 4.6 KB
--- nextStepFile: 'step-03-pins.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' depsScript: 'scripts/campaign-deps.py' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Strategy ## STEP GOAL: Compute the execution order from dependency edges, detect circular dependencies, and present a human-readable strategy view to the operator so the campaign plan is visible before execution begins. ## RULES - This step uses the **read-backup-modify-write** pattern (state file exists from step-01). - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Write `execution_order` and `circular_deps_detected` to `dependency_graph`. - Update `campaign.current_stage` to `1`. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - If `{headless_mode}` is true, auto-proceed through confirmation gates with the default action and log each auto-decision. ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Backup State Copy `{stateFile}` to `{backupFile}` before any modification. ### §3 — Read Directive If `campaign.directive_path` is set in state, load the file at that path and apply its contents as campaign-wide context for this stage's processing, per the directive contract in `references/campaign-directive-spec.md`. If the file is not found, continue without error (directive is optional). ### §4 — Compute Execution Order Run the deterministic topological sort — do not hand-compute it: ``` uv run {depsScript} --compute --state-file {stateFile} ``` Parse the JSON output: `execution_order` (the ordered skill names — Kahn's sort with Tier A placed before Tier B within a dependency level), `circular_deps_detected` (bool), `cycle_participants` (the unplaced skills when a cycle exists, else null), and `tier_counts` (`{"A": n, "B": m}`, for the §7 strategy view). Script exit 1 signals an unorderable graph — a cycle or a dangling `depends_on` reference — handled at §5. Script exit 2 signals the helper could not read/parse the state file; HALT (exit code 2, `invalid-input`) surfacing its error. ### §5 — Handle Unorderable Graph If the graph cannot be ordered (script exit 1), HALT (exit code 4, `circular-deps`) — the execution order is impossible, so do not proceed. Two cases: - **Cycle** (`circular_deps_detected: true`): list `cycle_participants` and their mutual `depends_on` edges. - **Dangling reference** (a `DANGLING_DEPENDENCY` error with no `execution_order`): name the skill and the unknown dependency it references. ### §6 — Write State Set `dependency_graph.execution_order` to the computed order. Set `dependency_graph.circular_deps_detected` to the detection result. Set `campaign.current_stage` to `1`. Set `campaign.last_updated` to current ISO-8601 with timezone. Write to `{stateFile}`. ### §7 — Present Strategy View Display a human-readable strategy summary to the operator (not written to a file — display only): ``` CAMPAIGN STRATEGY: {campaign_name} EXECUTION ORDER: 1. {skill_name} [Tier {tier}] {pin or "latest"} 2. {skill_name} [Tier {tier}] {pin or "latest"} ← depends on: {dep1, dep2} ... DEPENDENCY MAP: {skill_a} → {skill_b}, {skill_c} {skill_d} → (no dependencies) ... QUALITY GATE: Hard gate: {quality_gate.hard} Soft target: {quality_gate.soft_target}% Soft fallback: {quality_gate.soft_fallback}% TIER DISTRIBUTION: Tier A (full pipeline): {count} Tier B (QS batch): {count} ``` Fill the TIER DISTRIBUTION counts from `tier_counts` in the §4 script output — do not re-tally `skills[]` by hand. ### §8 — Plan Confirmation Gate The strategy view is the last review surface before a potentially long, mostly-unattended run begins. Present a confirmation gate: - `[P]roceed` — begin execution (chain to `{nextStepFile}`). - `[C]ancel` — stop now with exit code 12 (`user-cancelled`); state is intact and resumable. To change targets, edit the campaign brief (`{campaignWorkspacePath}/campaign-brief.yaml`) or re-run `campaign` (overwrite), then start again. **HALT and wait for operator input.** In headless mode, auto-proceed with `[P]` and log "headless: auto-proceed past plan-confirmation gate" to the decision log. ## OUTPUT Confirm strategy computed, display the strategy view, and resolve the §8 gate. Chain to `{nextStepFile}`. -
step-03-pins.md 2.9 KB
--- nextStepFile: 'step-04-provenance.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' briefFile: '{campaignWorkspacePath}/campaign-brief.yaml' pinScript: 'scripts/campaign-validate-pins.py' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Pins ## STEP GOAL: Validate all version pins against real releases/branches before the campaign proceeds, catching invalid pins early with actionable suggestions. ## RULES - This step uses the **read-backup-modify-write** pattern (state file exists from step-01). - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.current_stage` to `2`. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - HALT (exit code 5, `invalid-pin`) on any invalid pin — invalid pins are errors, not gates. - If `{headless_mode}` is true, auto-proceed through confirmation gates with the default action and log each auto-decision. ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Read Brief Load `{briefFile}`. Build a lookup map from `targets[].name` to `targets[].repo_url`. HALT (exit code 8, `missing-brief`) if the brief is missing or unreadable. ### §3 — Backup State Copy `{stateFile}` to `{backupFile}` before any modification. ### §4 — Validate Pins Run `uv run {pinScript} --state-file {stateFile} --brief-file {briefFile}`. If the script exits 2 (a required tool such as `gh` is unavailable, or a file is unreadable), HALT (exit code 2, `invalid-input`) surfacing its error — pins cannot be validated without `gh`. Otherwise parse the JSON output. For each result: if `status` is `"valid"` or `"resolved"`, the pin is good; if `"invalid"`, collect the failure with suggestions. ### §5 — Handle Invalid Pins If ANY pins are invalid, collect ALL failures first (all-or-nothing pattern), then HALT (exit code 5, `invalid-pin`) with a clear error listing each invalid pin, the skill name, the attempted pin value, and suggested corrections. Do NOT partially proceed. ### §6 — Update State For each skill where validation returned `status: "resolved"` (pin was null, latest release found), update `skill.pin` to the `resolved_ref` value. For `status: "valid"` where the input pin differs from `resolved_ref` (e.g., user said `2.0.0` but the actual tag is `v2.0.0`), update `skill.pin` to the `resolved_ref` so downstream steps use the exact ref name. Set `campaign.current_stage` to `2`. Set `campaign.last_updated`. Write to `{stateFile}`. ## OUTPUT Display pin validation summary — for each skill: name, pin, resolved ref, ref type. Chain to `{nextStepFile}`. -
step-04-provenance.md 3.3 KB
--- nextStepFile: 'step-05-skill-loop.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' briefFile: '{campaignWorkspacePath}/campaign-brief.yaml' provenanceScript: 'scripts/campaign-provenance.py' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Provenance ## STEP GOAL: Verify that all target repositories are accessible and record the exact commit SHA for each target, establishing the provenance baseline for the campaign. ## RULES - This step uses the **read-backup-modify-write** pattern. - Reads the brief for `repo_url` (not in state — `repo_url` is NOT part of the state schema). - Any inaccessible repo halts the campaign — all targets must be reachable before skill processing begins. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.current_stage` to `3`. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - If `{headless_mode}` is true, auto-proceed through confirmation gates with the default action and log each auto-decision. ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Read Brief Load `{briefFile}` only to confirm it parses (the provenance script reads it directly). HALT (exit code 8, `missing-brief`) if the brief is missing or unreadable. ### §3 — Backup State Copy `{stateFile}` to `{backupFile}` before any modification. ### §4 — Verify Repo Access + Record Commit SHAs Run the deterministic provenance check — do not parse repo URLs or shell out to `gh` by hand: ``` uv run {provenanceScript} --state-file {stateFile} --brief-file {briefFile} ``` The script resolves `{owner}/{repo}` from each `repo_url` (tolerating `.git`/trailing slashes/SSH form), picks the ref (the skill's `pin`, else the repo default branch), runs `gh repo view` + `gh api commits`, and emits `results[]` with `commit_sha` and `status` per skill, plus `all_accessible`, `inaccessible_count`, and `systemic_hint`. Exit 0 = all accessible, 1 = one or more inaccessible, 2 = error (missing files, bad YAML, `gh` not installed). On script exit 2, HALT (exit code 2, `invalid-input`) surfacing the error — repo access cannot be verified without `gh`. ### §5 — Handle Inaccessible Repos If `all_accessible` is `false` (script exit 1), HALT (exit code 6, `inaccessible-repo`). When the script returns a non-null `systemic_hint` (every target failed the same way — e.g. unauthenticated `gh`, no network, rate limit), present that single root-cause line instead of a wall of near-identical per-repo errors. Otherwise list each inaccessible repo, its URL, and its error. Do NOT partially proceed — all repos must be verified before writing state. ### §6 — Write State Set each skill's `commit_sha` from the script's `results[]`. Set `campaign.current_stage` to `3`. Set `campaign.last_updated` to current ISO-8601 with timezone. Write to `{stateFile}`. ## OUTPUT Display provenance summary — for each target, show name, repo URL, and recorded commit SHA. Chain to `{nextStepFile}`. -
step-05-skill-loop.md 8.6 KB
--- nextStepFile: 'step-06-batch.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' briefFile: '{campaignWorkspacePath}/campaign-brief.yaml' depsScript: 'scripts/campaign-deps.py' kickoffTemplate: '{kickoffTemplatePath}' kickoffScript: 'scripts/campaign-render-kickoff.py' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Skill Loop ## STEP GOAL: Iterate skills in `dependency_graph.execution_order`, processing each Tier A skill through the full pipeline while enforcing dependency gates. Write state after each skill completes to survive context death between skills. ## RULES - This step uses the **read-backup-modify-write** pattern. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.current_stage` to `4`. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - Write state after EACH skill completes (not just at end) — context death between skills must be survivable. - The per-skill pipeline body (§5.2) runs inline, not in a delegated subagent: AN→BS→CS→TS are nested skill activations, and a subagent cannot spawn further subagents — running inline keeps the full pipeline reachable and writes each skill's state before the next begins. - If `{headless_mode}` is true, auto-proceed through confirmation gates. Dependency gate blocks default to HALT (safest — never silently skip dependencies). ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Read Brief Load `{briefFile}`. Build a lookup map from `targets[].name` to `targets[].repo_url`. HALT (exit code 8, `missing-brief`) if the brief is missing or unreadable. ### §3 — Read Directive If `campaign.directive_path` is set in state, load the file at that path and apply its contents as campaign-wide context for all skill processing, per the directive contract in `references/campaign-directive-spec.md`. If the file is not found, continue without error (directive is optional). ### §4 — Dependency Gate Check For each skill in `dependency_graph.execution_order`, before processing: 1. Skip Tier B skills — they are processed in step-06 via batch mode. 2. Skip skills whose status is already `"completed"`, `"failed"`, or `"skipped"` (resume support). 3. Run `uv run {depsScript} --check --state-file {stateFile} --skill {skill_name}`. 4. If `ready: true` — proceed to §5 for this skill. 5. If `ready: false` — present the blocked skill and its unmet dependencies: - `[S]kip` — mark skill as `"skipped"`, backup and write state, continue to next skill. - `[F]orce` — re-run with `--force`, proceed to §5 despite unmet deps. - `[H]alt` — stop the campaign loop. (Default in headless mode.) 6. **Deadlock detection:** after iterating through all remaining skills and finding none ready, present the same recovery menu as §4.5, scoped to the mutually-blocked set (this is the strictly harder situation, so it must not get worse UX than a single blocked skill): - List the blocked skills and their unmet dependencies. - `[F]orce one` — choose a skill to re-run with `--force` and resume the loop from it. - `[S]kip one` — choose a skill to mark `"skipped"`, backup and write state, then re-evaluate readiness. - `[H]alt` — stop the campaign loop with exit code 7 (`dependency-deadlock`). **Default in headless mode** (never silently force or skip a dependency). Log the chosen action to the decision log. ### §5 — Per-Skill Processing For each ready Tier A skill: 1. **Activate** — set `status` to `"active"`, set `started_at` to current ISO-8601 with timezone. Backup and write state. 2. **Execute pipeline:** - **Pre-apply** — apply known workarounds before generation by running the shared pre-apply helper against the skill's working directory: ``` uv run {project-root}/_bmad/skf/shared/scripts/skf-preapply.py --target-dir <skill-working-dir> --log-dir {campaignWorkspacePath} ``` (During development the helper lives at `src/shared/scripts/skf-preapply.py`.) Parse `applied[]` from the JSON output and capture the list of applied workarounds. Pre-apply is best-effort: if the helper is missing or exits non-zero, log a warning and proceed — it is not a gate. - **Kickoff emit** — render the mechanical placeholders deterministically, then fill the three judgment slots. Run: ``` uv run {kickoffScript} --state-file {stateFile} --brief-file {briefFile} --skill {skill_name} --template {kickoffTemplate} --workarounds '<JSON list of applied workarounds from pre-apply>' ``` The script fills `{{campaign_name}}`, `{{current_stage}}`, `{{quality_gate_summary}}`, `{{skill_name}}`, `{{skill_tier}}`, `{{pin}}`, `{{commit_sha}}`, `{{repo_url}}`, `{{workarounds_list}}`, and `{{dependency_status_table}}` from state + brief. Then fill the three judgment slots that remain in the rendered output: - `{{brief_summary}}` — a concise summary of this target's brief entry (name, repo_url, tier, pin, depends_on). The per-skill brief does NOT exist yet at kickoff — BS produces it during this pipeline run (see below), so do not read `brief_path`; summarize the campaign brief's target entry instead. - `{{persistent_facts}}` — the campaign-wide persistent facts resolved in On Activation (literal sentences and loaded `file:` contents), as a bullet list, or "None" if empty. This is how house style/guardrails reach every skill's pipeline. - `{{directive_content}}` — raw content of the file at `campaign.directive_path`, or "No directive configured" if unset/missing. Present the completed kickoff message as the context for the skill's pipeline run. - **AN → BS → CS → TS** — standard forge pipeline for this skill. When BS (Brief Synthesis) produces the skill's brief, set `skills[current].brief_path` to the brief path from the BS result envelope so the field the schema declares is populated and available for resume and reporting. - **Doc-rot check** — **record** what CS step 5c already produced; do not re-derive it. Because the pipeline body runs inline (see the §5 note above), that step's context is directly readable here: `corrections_added`, `correction_matches` (the enriched match records — `source`, `pattern`, `category`, `context_line`, `affected`), `corrections_deduped` and `corrections_capped`. Append one `[doc-rot]`-prefixed entry per `correction_matches` record to the skill's `workarounds_applied` array so they survive state write and are available for §6 propagation. When step 5c took its skip branch (`doc_rot_triggered: false`, `corrections_added: 0`), there is nothing to record — append nothing and move on. **Do not hand-grep the feeder artifacts here, and do not re-run the scan.** `skf-create-skill/references/step-doc-rot.md` §2 requires the scan to run in its helper precisely so identical feeders yield identical matches; a second pass in this loop is free to disagree with the corrections CS actually wrote. It would also be self-contaminating: step 5c has already written `## CORRECTION` blocks into the compiled SKILL.md, and the helper's exclusion windows cover only that file's frontmatter and its Migration & Deprecation Warnings section — so re-scanning it would re-match the blocks 5c itself authored and record them as fresh findings. 3. **Record results:** - On success: set `status` to `"completed"`, set `completed_at` to current ISO-8601 with timezone, record `quality_score`. Backup and write state. - On failure: set `status` to `"failed"`. Log the failure reason (the sub-skill's `halt_reason`/exit code, or "unparseable result envelope") to the decision log so `campaign status` and the report surface *why* a skill failed without the operator opening sub-skill logs. Backup and write state. Downstream skills whose `depends_on` does NOT include the failed skill continue processing normally; those that DO depend on it are blocked at §4's dependency gate. ### §6 — Propagate Findings After each completed skill, propagate quality findings and doc-rot corrections to campaign-level tracking (`workarounds_applied`, `quality_score`). ### §7 — Loop Completion When all Tier A skills in `execution_order` are processed (completed, failed, or skipped): 1. Set `campaign.current_stage` to `4`. 2. Set `campaign.last_updated` to current ISO-8601 with timezone. 3. Backup and write state. ## OUTPUT Display per-skill summary: name, status, quality_score (if completed). Chain to `{nextStepFile}`. -
step-06-batch.md 4.1 KB
--- nextStepFile: 'step-07-capstone.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' briefFile: '{campaignWorkspacePath}/campaign-brief.yaml' batchFile: '{campaignWorkspacePath}/_batch-input.txt' batchScript: 'scripts/campaign-render-batch.py' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Tier B Batch ## STEP GOAL: Batch all Tier B skills through QS `--batch` mode, recording per-skill results in campaign state. Tier B skills use a faster, simpler path than the full Tier A pipeline — QS handles each target end-to-end in a single invocation. ## RULES - This step uses the **read-backup-modify-write** pattern. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - Update `campaign.current_stage` to `5`. - If `{headless_mode}` is true, auto-proceed through confirmation gates. QS `--batch` implies headless. ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Read Directive If `campaign.directive_path` is set in state, load the file at that path and apply its contents as campaign-wide context for this stage's processing, per the directive contract in `references/campaign-directive-spec.md`. If the file is not found, continue without error (directive is optional). ### §3 — Identify Tier B Skills Filter `skills[]` for entries where `tier == "B"` and `status == "pending"`. Skip skills with status `"completed"`, `"failed"`, or `"skipped"` (resume support — a previous run may have partially completed the batch). If no Tier B skills need processing, skip to §7 (Stage Completion) — the batch stage completes immediately when all Tier B skills are already handled. ### §4 — Generate Batch File Generate the QS `--batch` input file deterministically: ``` uv run {batchScript} --state-file {stateFile} --brief-file {briefFile} -o {batchFile} ``` The script filters `skills[]` for `tier == "B" && status == "pending"`, looks up each skill's `repo_url` from the brief's `targets[]` (repo URLs live in the brief, not the state schema), and writes one line per skill at `{batchFile}` in the exact single-target shape QS parses (see `src/skf-quick-skill/references/batch-mode.md`) — the line format is owned once by the script, not re-derived here. It emits a JSON summary (`written`, `count`, `skipped_non_tierB`, `skipped_non_pending`) on stderr. HALT on non-zero exit: exit code 8 (`missing-brief`) when the brief is missing/unreadable **or** a pending Tier B skill has no matching brief target; exit code 2 (`invalid-input`) on a state file/parse error. ### §5 — Execute QS Batch Set each pending Tier B skill to `status: "active"` and `started_at` to current ISO-8601 with timezone. Backup `{stateFile}` to `{backupFile}`, then write the updated state. Invoke QS in `--batch` mode with the generated batch file: ``` skf-quick-skill --batch {batchFile} ``` QS `--batch` implies `--headless`. Capture per-skill results from the QS batch output — each target reports success/failure, skill path, and quality score. ### §6 — Record Results For each Tier B skill in the batch: 1. If QS reports success: - Set `status` to `"completed"` - Set `completed_at` to current ISO-8601 with timezone - Record `quality_score` from QS output - Record `skill_path` from QS output 2. If QS reports failure: - Set `status` to `"failed"` After all updates: backup `{stateFile}` to `{backupFile}`, then write the updated state. ### §7 — Stage Completion Set `campaign.current_stage` to `5`. Update `campaign.last_updated` to current ISO-8601 with timezone. Backup `{stateFile}` to `{backupFile}`, then write the updated state. ## OUTPUT Display per-skill batch summary: name, status, quality_score (if completed). Chain to `{nextStepFile}`. -
step-07-capstone.md 3.4 KB
--- nextStepFile: 'step-08-verify.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Capstone ## STEP GOAL: Compose a capstone stack skill from all completed individual skills using SS compose-mode. The capstone represents the final integrated view of all campaign skills — a single stack skill that documents how the constituent libraries connect. ## RULES - This step uses the **read-backup-modify-write** pattern. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - Update `campaign.current_stage` to `6`. - If `{headless_mode}` is true, auto-proceed through confirmation gates. SS compose-mode supports headless. ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Collect Completed Skills Gather all skills from `skills[]` with `status == "completed"` — this includes both Tier A skills (processed in step-05) and Tier B skills (processed in step-06). Extract their `skill_path` values. If no completed skills exist, do NOT HALT — a campaign where everything failed is exactly when the operator most needs the downstream diagnostic report. Set `campaign.capstone` to `null`, warn ("No completed skills — skipping capstone composition; verification and the campaign report will still run so failures are explained"), log the skip to the decision log, and skip directly to §5 (Stage Completion) so the chain continues to verify → … → the report. step-10 (export) and step-11 (report) already handle the zero-completed case. ### §3 — Invoke SS Compose-Mode Invoke `skf-create-stack-skill` in compose-mode with: - The collected `skill_path` values as input skills - `campaign.name` as the stack identifier Capture the result: stack skill path and quality score from the SS result output (`SKF_STACK_RESULT_JSON`). ### §4 — Record Capstone Results Persist the capstone outcome to `campaign.capstone` in the state (campaign-level summary; the composed skill itself lives at `skill_path`): - `campaign.capstone.skill_path` — stack skill path (from SS result) - `campaign.capstone.quality_score` — quality score (from SS result) - `campaign.capstone.verified` — `null` for now; set by the verify stage (step-08) once the stack is checked - `campaign.capstone.completed_at` — current ISO-8601 with timezone The capstone is a derived artifact — it is **not** tracked as a skill entry in the `skills[]` array. Its campaign-level summary lives in `campaign.capstone`; the constituent skill list and any verbose detail are reported in the step output and are available to downstream steps (verify, refine). ### §5 — Stage Completion Set `campaign.current_stage` to `6`. Update `campaign.last_updated` to current ISO-8601 with timezone. Backup `{stateFile}` to `{backupFile}`, then write the updated state (including `campaign.capstone` from §4). ## OUTPUT Display capstone summary: stack skill name, path, quality score, and the list of constituent skills. Chain to `{nextStepFile}`. -
step-08-verify.md 4.3 KB
--- nextStepFile: 'step-09-refine.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Verify ## STEP GOAL: Invoke VS (skf-verify-stack) in headless mode against all completed campaign skills to produce a feasibility report. The report cross-references generated skills against the project's architecture document, providing coverage analysis and integration verdicts for operator review. ## RULES - This step uses the **read-backup-modify-write** pattern. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - Update `campaign.current_stage` to `7`. - If `{headless_mode}` is true, auto-proceed through confirmation gates. VS supports headless via `--headless`. ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Read Directive If `campaign.directive_path` is set in state, load the file at that path and apply its contents as campaign-wide context for this stage's processing, per the directive contract in `references/campaign-directive-spec.md`. If the file is not found, continue without error (directive is optional). ### §3 — Locate Architecture Doc Resolve the architecture document path, preferring the value persisted in state: 1. If `campaign.architecture_doc_path` is set in state and the file exists, use it directly. 2. Otherwise discover it: check `docs/architecture.md` at `{project-root}` (SKF convention), then `_bmad-output/planning-artifacts/architecture.md` (BMM convention). 3. If still not found and `{headless_mode}` is false: prompt the operator to provide the architecture doc path. 4. If still not found and `{headless_mode}` is true: skip VS invocation with a warning — do not HALT. Log that verification was skipped due to missing architecture doc (to the decision log) and proceed to §6. Once resolved (steps 2–3), persist the path to `campaign.architecture_doc_path` so the refine stage and any resume reuse it without re-prompting. Then proceed to §4 with the resolved path. ### §4 — Invoke VS Invoke `skf-verify-stack` with `--headless --architecture-doc <path>`, where `<path>` is the architecture doc discovered in §3. VS discovers skills from its own configured `{skills_output_folder}` — the campaign does NOT pass individual skill paths. Capture the result envelope from stdout: ``` SKF_VERIFY_STACK_RESULT_JSON: {"status":"…","report_path":"…","report_latest_path":"…","overall_verdict":"…","coverage_percentage":0,"recommendation_count":0,"exit_code":0,"halt_reason":null} ``` ### §5 — Handle VS Outcome **On success** (exit code 0): persist the summary to `campaign.verification` (detailed findings stay in the external report): - `campaign.verification.report_path` — `report_latest_path` from the envelope - `campaign.verification.overall_verdict` — one of `Verified`, `Plausible`, `Risky`, `Blocked` - `campaign.verification.coverage_percentage` — from the envelope - `campaign.verification.recommendation_count` — from the envelope Also set `campaign.capstone.verified` to `true` when `overall_verdict == "Verified"`, otherwise `false` (only if a `campaign.capstone` entry exists from step-07). **On VS failure** (non-zero exit): log the error (exit code and halt_reason from the envelope or stderr). Verification failure does NOT block the campaign — it produces diagnostic information for operator review. Leave `campaign.verification` unset (or null). Continue to §6 regardless of outcome. ### §6 — Stage Completion Set `campaign.current_stage` to `7`. Update `campaign.last_updated` to current ISO-8601 with timezone. Backup `{stateFile}` to `{backupFile}`, then write the updated state (including `campaign.architecture_doc_path` from §3 and `campaign.verification` from §5). ## OUTPUT Display verification summary: overall verdict (or "skipped" if architecture doc was not found), report path (if produced), and coverage percentage. Chain to `{nextStepFile}`. -
step-09-refine.md 4.5 KB
--- nextStepFile: 'step-10-export.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Refine ## STEP GOAL: Invoke RA (skf-refine-architecture) in headless mode with the project's architecture document and VS feasibility report to produce a refined architecture. RA identifies gaps, issues, and improvements based on the generated skills and applies them to the architecture document. ## RULES - This step uses the **read-backup-modify-write** pattern. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - Update `campaign.current_stage` to `8`. - If `{headless_mode}` is true, auto-proceed through confirmation gates. RA supports headless via `--headless`. ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Read Directive If `campaign.directive_path` is set in state, load the file at that path and apply its contents as campaign-wide context for this stage's processing, per the directive contract in `references/campaign-directive-spec.md`. If the file is not found, continue without error (directive is optional). ### §3 — Locate Inputs **Architecture doc:** Use the same resolution strategy as step-08: 1. If `campaign.architecture_doc_path` is set in state and the file exists, use it directly (step-08 normally persists it). 2. Otherwise check `docs/architecture.md` at `{project-root}`, then `_bmad-output/planning-artifacts/architecture.md`. 3. If still not found and `{headless_mode}` is false: prompt the operator. 4. If still not found and `{headless_mode}` is true: skip RA invocation with a warning — do not HALT. Log that refinement was skipped due to missing architecture doc (to the decision log) and proceed to §6. Once resolved (steps 2–3), persist the path to `campaign.architecture_doc_path` if not already set, then proceed to §4 with the resolved path. **VS feasibility report:** If chaining from step-08, the report path is available from the VS result envelope (`report_latest_path`). On resume, look for `feasibility-report-*-latest.md` in `{forge_data_folder}/`. If no report exists (VS may have failed or been skipped in step-08), proceed without it — RA's VS report input is optional. ### §4 — Invoke RA Invoke `skf-refine-architecture` with: ``` skf-refine-architecture --headless --architecture-doc <arch_path> [--vs-report-path <report_path>] [--scope-skills <names>] ``` - `--architecture-doc`: the architecture doc discovered in §3 (required). - `--vs-report-path`: the VS feasibility report path from §3 (omit if not found). - `--scope-skills`: comma-separated names of completed campaign skills (from `skills[]` where `status == "completed"`). Optional but improves focus by limiting refinement scope to campaign-relevant skills. Capture the result envelope from stdout: ``` SKF_REFINE_ARCHITECTURE_RESULT_JSON: {"status":"…","refined_path":"…","gap_count":0,"issue_count":0,"improvement_count":0,"exit_code":0,"halt_reason":null} ``` ### §5 — Handle RA Outcome **On success** (exit code 0): persist the summary to `campaign.refinement` (the refined document itself lives at `refined_path`): - `campaign.refinement.refined_path` — from the envelope - `campaign.refinement.gap_count` — from the envelope - `campaign.refinement.issue_count` — from the envelope - `campaign.refinement.improvement_count` — from the envelope **On RA failure** (non-zero exit): log the error (exit code and halt_reason from the envelope or stderr). Refinement failure does NOT block the campaign — the campaign continues to export with whatever state exists. Leave `campaign.refinement` unset (or null). Continue to §6 regardless of outcome. ### §6 — Stage Completion Set `campaign.current_stage` to `8`. Update `campaign.last_updated` to current ISO-8601 with timezone. Backup `{stateFile}` to `{backupFile}`, then write the updated state (including `campaign.refinement` from §5). ## OUTPUT Display refinement summary: refined architecture path (or "skipped" if architecture doc was not found), gap count, issue count, and improvement count. Chain to `{nextStepFile}`. -
step-10-export.md 3.9 KB
--- nextStepFile: 'step-11-maintenance.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Export ## STEP GOAL: Present all completed skills for operator review and gate the export behind explicit confirmation. This is the only campaign step that requires manual approval before proceeding — no files are written until the operator confirms. ## RULES - This step uses the **read-backup-modify-write** pattern. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - Update `campaign.current_stage` to `9`. - If `{headless_mode}` is true, auto-proceed past the write-gate with `[E]` and log: "headless: auto-proceed past export write-gate". ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Read Directive If `campaign.directive_path` is set in state, load the file at that path and apply its contents as campaign-wide context for this stage's processing, per the directive contract in `references/campaign-directive-spec.md`. If the file is not found, continue without error (directive is optional). ### §3 — Collect Export Candidates Gather all skills from `skills[]` with `status == "completed"`. These are the export candidates. If no completed skills exist, display a warning and proceed directly to §6 (stage completion) — there is nothing to export. Present a summary table of export candidates: | # | Name | Tier | Quality Score | Skill Path | |---|------|------|---------------|------------| | 1 | {name} | {tier} | {quality_score} | {skill_path} | | ... | ... | ... | ... | ... | Display: "**{N} skill(s) ready for export.**" ### §4 — Write-Gate HALT Present the export confirmation gate: "**Export Gate — Confirm before writing files** {N} completed skill(s) will be exported via `skf-export-skill`: {summary table from §3} - **[E]xport all** — invoke `skf-export-skill` for each completed skill - **[C]ancel** — halt the campaign gracefully (no files written, resume later) Choose [E] or [C]:" **HALT and wait for operator input.** **Headless mode:** auto-proceed with `[E]` and log: "headless: auto-proceed past export write-gate". #### On `[C]ancel`: Display: "Export cancelled by operator. Campaign halted gracefully — no files written. Resume later to retry export." Log the cancellation to the decision log, then HALT with exit code 11 (`export-cancelled`). Do NOT mark the campaign as failed — this is a graceful, resumable halt; the operator may resume later. #### On `[E]xport`: Log the export decision to the decision log, then proceed to §5. ### §5 — Invoke EX For each completed skill (from §3), invoke `skf-export-skill` in headless mode: ``` skf-export-skill {skill_name} --headless ``` Capture the result envelope `SKF_EXPORT_RESULT_JSON` per skill. **On per-skill EX success** (exit code 0): log the result and continue. **On per-skill EX failure** (non-zero exit): log the error (exit code, envelope if available, or stderr). Continue with remaining skills — per-skill failure does not block remaining exports. After all exports complete, display a summary: "**Export Results:** - Exported: {success_count} skill(s) - Failed: {fail_count} skill(s) {list of failed skills if any}" ### §6 — Stage Completion Set `campaign.current_stage` to `9`. Update `campaign.last_updated` to current ISO-8601 with timezone. Backup `{stateFile}` to `{backupFile}`, then write the updated state. ## OUTPUT Display export summary: skills exported count, failures count (if any), and per-skill results. Chain to `{nextStepFile}`. -
step-11-maintenance.md 4.4 KB
--- nextStepFile: 'health-check.md' stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' reportFile: '{campaignWorkspacePath}/campaign-report.md' reportScript: 'scripts/campaign-report.py' reportTemplate: '{reportTemplatePath}' validateScript: 'scripts/campaign-validate-state.py' --- <!-- Config: communicate in {communication_language}. --> # Maintenance ## STEP GOAL: Generate a comprehensive campaign report from the accumulated state, emit the headless result envelope, and chain to the shared health check as the campaign's terminal step. ## RULES - This step uses the **read-backup-modify-write** pattern. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero. - Update `campaign.last_updated` to current ISO-8601 with timezone on every write. - Update `campaign.current_stage` to `10`. - If `{headless_mode}` is true, auto-proceed through confirmation gates. Emit the headless envelope on stdout. ## TASKS ### §1 — Read + Validate State Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`. ### §2 — Generate Campaign Report Invoke the campaign report script: ``` uv run {reportScript} \ --state-file {stateFile} \ --template-file {reportTemplate} \ --output-file {reportFile} ``` Capture the JSON result from stdout — it carries `skills_completed`, `skills_failed`, `quality_scores`, and `duration` already computed. Do not recompute these by hand in §3. **On success** (exit code 0): log the report path and summary stats from the result JSON. **On failure** (exit code 2): the campaign itself has already completed — do NOT discard it over a missing summary artifact. Display the error from stderr, log "report generation failed (degraded): {error}" to the decision log, set the envelope's `campaign_report_path` to `null`, and CONTINUE to §3. Surface `report-failure` only as a degraded signal in the envelope (`status:"error"`, `exit_code:10`), never as a hard halt that throws away a finished campaign. Display: "**Campaign report generated:** `{reportFile}`" (or, on degrade, "**Campaign complete — report generation failed (see decision log); state is intact.**") **Optional post-completion hook:** if `{onComplete}` (resolved in On Activation) is non-empty, invoke `{onComplete} --report-path={reportFile}`. Log the outcome to the decision log; a hook failure is recorded but never fails the campaign. ### §3 — Emit Headless Envelope When `{headless_mode}` is true, emit the campaign result envelope on stdout, copying the counts, `quality_scores`, and `duration` straight from the §2 report-script result (do not recompute): ``` SKF_CAMPAIGN_RESULT_JSON: {"status":"success","skills_completed":N,"skills_failed":N,"quality_scores":{...},"campaign_report_path":"{reportFile}","decision_log":"{campaignWorkspacePath}/_campaign-decision-log.md","duration":"..."} ``` - `status`: "success" if the campaign completed normally (HARD HALTs emit the error variant per the "Result Contract on HARD HALT" in `references/campaign-contracts.md`) - `skills_completed` / `skills_failed` / `quality_scores` / `duration`: from the §2 report-script result JSON - `campaign_report_path`: `{reportFile}` - `decision_log`: path to the append-only decision log When not in headless mode, skip this section silently. ### §4 — Stage Completion Set `campaign.current_stage` to `10`. Update `campaign.last_updated` to current ISO-8601 with timezone. Backup `{stateFile}` to `{backupFile}`, then write the updated state. ### §5 — Chain to Health Check The operator's findings-routing consent (`campaign.health_findings_queue`) is applied by the terminal health-check step: `references/health-check.md` §1 reads it from state and carries it into the shared health check as the pre-decided opt-in (`"improvement"` → route non-bug findings to the shared improvement queue without re-prompting; `"local"` → local queue only). Surface the active setting here. Display: "**Campaign complete.** Report at `{reportFile}`. Findings routing: {campaign.health_findings_queue}. Chaining to health check..." Chain to `{nextStepFile}` (shared/health-check.md). ## OUTPUT Display campaign completion summary: skills completed, skills failed, report path, total duration. Chain to `{nextStepFile}`. -
step-resume.md 7.4 KB
--- stateSchemaFile: 'assets/campaign-state-schema.json' stateFile: '{campaignWorkspacePath}/_campaign-state.yaml' backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak' validateScript: 'scripts/campaign-validate-state.py' statusScript: 'scripts/campaign-status.py' --- <!-- Config: communicate in {communication_language}. --> # Resume ## STEP GOAL: Validate campaign state integrity, determine the resume point, and chain to the appropriate stage step file. This is a read-only routing step — it does not modify state. ## RULES - This step is **read-only by default** — it does NOT modify `{stateFile}` except in the one recovery case below (restoring a valid `.bak` over a corrupt primary), which is the State Contract's advertised safety net. - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; the recovery path in §1 governs what happens on failure. - The chain target is determined dynamically from state — there is no fixed `nextStepFile`. - If `{headless_mode}` is true, auto-proceed through any confirmation gates with the default action and log each auto-decision. ## TASKS ### §1 — Read + Validate State (with `.bak` recovery) Load `{stateFile}`. If the file does not exist, HALT (exit code 2, `invalid-input`): "No campaign state found. Run `campaign` to start a new campaign." Run `uv run {validateScript} --state-file {stateFile}`. If it succeeds (exit 0), proceed to §2. If it fails (primary missing/corrupt YAML/schema-invalid), **attempt automatic recovery from the backup** rather than dead-halting — this is exactly the crash-during-write case the State Contract promises `.bak` covers: 1. If `{backupFile}` exists, run `uv run {validateScript} --state-file {backupFile}`. 2. If the backup is valid: copy `{backupFile}` over `{stateFile}`, log to the decision log "primary corrupt — recovered from backup as of {bak.last_updated}", inform the operator, and continue with the recovered state. 3. If the backup is missing or also invalid: HALT with exit code 9 (`corrupt-state`), reporting both the primary and backup validation errors so the operator knows neither is usable. ### §2 — Backup Consistency Check Check if `{backupFile}` exists. **If `.bak` does not exist:** warn "No backup file found — campaign may have been created but never modified." Continue. **If `.bak` exists** (and §1 did not already recover from it): 1. Run `uv run {validateScript} --state-file {backupFile}`. If invalid, warn: "Backup file fails validation — cannot use for recovery." Continue with the primary. 2. Compare primary vs backup deterministically: run `uv run {statusScript} --state-file {stateFile} --backup-file {backupFile}` and read `backup_comparison.primary_behind` (the script does the ISO-8601 timestamp and `current_stage` compares — do not order the timestamps by hand). If it is `true`, the primary looks behind the backup (possible crash during last write). Present a recovery choice: - `[R]ecover` — copy `{backupFile}` over `{stateFile}` and resume from the backup's state. - `[K]eep` — keep the primary as authoritative (the default). In headless mode, default to `[K]eep` and log the auto-decision (a behind-backup primary may be intentional; never silently overwrite without a clear corruption signal — that case is handled in §1). Otherwise the primary is authoritative. ### §3 — Determine Resume Point Two paths based on whether `--from=<skill>` was provided in the invocation: **With `--from=<skill>`:** 1. Find the named skill in `skills[]` by `name`. 2. If not found → HALT (exit code 2, `invalid-input`): "Unknown skill '{name}'. Known skills: {comma-separated list of all skill names from state}." 3. If the skill's `status` is `"completed"`, `"failed"`, or `"skipped"`, the operator may have meant to re-run it (e.g. it passed with a low score) rather than skip past it. Present a choice: - `[R]e-run` — reset the named skill to `"pending"` and resume from its stage. (Read-only step caveat: this single status reset follows read-backup-modify-write — back up first.) - `[N]ext` — find the next skill in `dependency_graph.execution_order` after the named one whose `status` is `"pending"` or `"active"` and resume there. If none found → HALT (exit code 0, campaign already complete): "All remaining skills are complete. Run `campaign` to start a new campaign." - `[H]alt` — stop without resuming. In headless mode, default to `[N]ext` and log the auto-decision. Log the chosen action to the decision log. 4. If an active skill already exists in `skills[]` AND it is a different skill from the `--from` target, warn: "Skill '{active_name}' is currently active — honoring explicit --from override." 5. Determine the target step file: - Tier A skill with status `"pending"` or `"active"` → stage 4 (`step-05-skill-loop.md`) - Tier B skill with status `"pending"` or `"active"` → stage 5 (`step-06-batch.md`) **Without `--from`:** 1. Scan `skills[]` for any skill with `status == "active"`. - If found and `tier == "A"` → resume target is stage 4 (`step-05-skill-loop.md`). The skill loop's §4 will skip completed skills until it reaches the active one. - If found and `tier == "B"` → resume target is stage 5 (`step-06-batch.md`). The batch step processes Tier B skills. 2. If no active skill → the next stage to run is `campaign.current_stage + 1`, because `current_stage` records the highest **completed** stage (each stage writes its own number only after its work and gates finish, so a mid-stage halt leaves the previous stage's number on disk). **Terminal cap:** if `campaign.current_stage` is `10`, the resolved stage is `10` itself (`step-11-maintenance.md`) — never `11`; the existing terminal-HALT check in §4 governs whether a stage-10 campaign is already done. 3. Map the resolved stage number to the corresponding step file using the stage table in §4. ### §4 — Resume Routing Map the resolved stage number to its step file. Both §3 branches feed this table an **already-resolved** stage number: the active-skill branch supplies stage 4 or 5 directly (and BYPASSES the `+1`), while the no-active-skill branch supplies `current_stage + 1` (terminal-capped at 10). Do not apply the `+1` again here. | Stage | Step File | |-------|-----------| | 0 | step-01-setup.md | | 1 | step-02-strategy.md | | 2 | step-03-pins.md | | 3 | step-04-provenance.md | | 4 | step-05-skill-loop.md | | 5 | step-06-batch.md | | 6 | step-07-capstone.md | | 7 | step-08-verify.md | | 8 | step-09-refine.md | | 9 | step-10-export.md | | 10 | step-11-maintenance.md | Derive the skill counts deterministically — `uv run {statusScript} --state-file {stateFile}` returns `completed`, `total`, and the per-status counts (`pending` / `active` / `failed` / `skipped`); do not hand-count `skills[]`. Display a resume summary before chaining: ``` CAMPAIGN RESUME: {campaign.name} Resuming from: Stage {stage_number} — {stage_name} Target skill: {skill_name} (if --from was used, otherwise "auto-detected" or "N/A") Skills completed: {completed} / {total} Skills remaining: {pending} pending, {active} active, {failed} failed, {skipped} skipped Last updated: {campaign.last_updated} ``` If `campaign.current_stage` is `10` and all skills have status `"completed"`, `"failed"`, or `"skipped"`: HALT (exit code 0, campaign already complete): "Campaign has reached its final stage. All skills have been processed." ## OUTPUT Chain to the determined step file.
-
-
scripts
-
.gitkeep 0 B · in bundle
-
campaign-deps.py 7 KB
# /// script # requires-python = ">=3.9" # dependencies = ["pyyaml"] # /// """Campaign Deps — dependency computation and enforcement for campaign skills. Two modes: --compute: topological sort of all skills from depends_on edges --check: verify a single skill's dependencies are satisfied CLI: uv run campaign-deps.py --compute --state-file <path> uv run campaign-deps.py --check --state-file <path> --skill <name> uv run campaign-deps.py --check --state-file <path> --skill <name> --force Output (JSON on stdout): --compute: {"execution_order": [...], "circular_deps_detected": bool, "cycle_participants": [...] | null, "tier_counts": {"A": int, "B": int}} --check: {"skill": "name", "ready": bool, "unmet_deps": [...], "forced": bool} Exit codes: 0 success / ready / force-override 1 circular deps / unmet deps / dangling reference 2 error (missing file, bad YAML) """ from __future__ import annotations import argparse import json import sys import heapq from pathlib import Path from typing import Any, Dict, List, Optional import yaml def _emit_error(message: str, code: str) -> None: json.dump({"error": message, "code": code}, sys.stderr) sys.stderr.write("\n") def _load_yaml(path: Path) -> Any: with open(path, encoding="utf-8") as f: return yaml.safe_load(f) def _build_skill_map(skills: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: return {s["name"]: s for s in skills} def _validate_deps( skill_map: Dict[str, Dict[str, Any]], ) -> Optional[List[str]]: dangling: List[str] = [] for name, skill in skill_map.items(): for dep in skill.get("depends_on", []) or []: if dep not in skill_map: dangling.append(f"{name} depends on unknown skill '{dep}'") return dangling if dangling else None def compute(state_file: str) -> int: path = Path(state_file) if not path.is_file(): _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND") return 2 try: state = _load_yaml(path) except Exception as exc: _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR") return 2 skills = state.get("skills", []) if not isinstance(skills, list): _emit_error("State file 'skills' is not an array", "INVALID_STATE") return 2 skill_map = _build_skill_map(skills) tier_counts = {"A": 0, "B": 0} for skill in skill_map.values(): tier = skill.get("tier") if tier in tier_counts: tier_counts[tier] += 1 dangling = _validate_deps(skill_map) if dangling: _emit_error( f"Dangling dependency references: {'; '.join(dangling)}", "DANGLING_DEPENDENCY", ) return 1 in_degree: Dict[str, int] = {name: 0 for name in skill_map} adjacency: Dict[str, List[str]] = {name: [] for name in skill_map} for name, skill in skill_map.items(): for dep in skill.get("depends_on", []) or []: adjacency[dep].append(name) in_degree[name] += 1 def _tier_key(n: str) -> tuple[int, str]: return (0 if skill_map[n].get("tier") == "A" else 1, n) heap: List[tuple[int, str]] = [] for n in skill_map: if in_degree[n] == 0: heapq.heappush(heap, _tier_key(n)) execution_order: List[str] = [] while heap: _, current = heapq.heappop(heap) execution_order.append(current) for dependent in adjacency[current]: in_degree[dependent] -= 1 if in_degree[dependent] == 0: heapq.heappush(heap, _tier_key(dependent)) if len(execution_order) < len(skill_map): cycle_participants = sorted( n for n in skill_map if n not in set(execution_order) ) output = { "execution_order": execution_order, "circular_deps_detected": True, "cycle_participants": cycle_participants, "tier_counts": tier_counts, } json.dump(output, sys.stdout, separators=(",", ":")) sys.stdout.write("\n") return 1 output = { "execution_order": execution_order, "circular_deps_detected": False, "cycle_participants": None, "tier_counts": tier_counts, } json.dump(output, sys.stdout, separators=(",", ":")) sys.stdout.write("\n") return 0 def check(state_file: str, skill_name: str, force: bool = False) -> int: path = Path(state_file) if not path.is_file(): _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND") return 2 try: state = _load_yaml(path) except Exception as exc: _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR") return 2 skills = state.get("skills", []) if not isinstance(skills, list): _emit_error("State file 'skills' is not an array", "INVALID_STATE") return 2 skill_map = _build_skill_map(skills) if skill_name not in skill_map: _emit_error(f"Skill '{skill_name}' not found in state", "SKILL_NOT_FOUND") return 2 deps = skill_map[skill_name].get("depends_on", []) or [] unmet: List[str] = [] for dep in deps: if dep not in skill_map: _emit_error( f"Skill '{skill_name}' depends on unknown skill '{dep}'", "DANGLING_DEPENDENCY", ) return 1 if skill_map[dep].get("status") != "completed": unmet.append(dep) ready = len(unmet) == 0 forced = force and not ready if forced: json.dump( {"warning": f"Forcing past unmet dependencies for '{skill_name}'", "unmet": unmet}, sys.stderr, ) sys.stderr.write("\n") output = { "skill": skill_name, "ready": ready, "unmet_deps": unmet, "forced": forced, } json.dump(output, sys.stdout, separators=(",", ":")) sys.stdout.write("\n") if not ready and not force: return 1 return 0 def main() -> int: parser = argparse.ArgumentParser( description="Campaign dependency computation and enforcement.", ) mode = parser.add_mutually_exclusive_group(required=True) mode.add_argument( "--compute", action="store_true", help="Compute execution order via topological sort", ) mode.add_argument( "--check", action="store_true", help="Check if a skill's dependencies are satisfied", ) parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml") parser.add_argument("--skill", help="Skill name (required for --check)") parser.add_argument( "--force", action="store_true", help="Force past dependency check (--check only)", ) args = parser.parse_args() if args.check and not args.skill: parser.error("--skill is required when using --check") if args.compute: return compute(args.state_file) return check(args.state_file, args.skill, force=args.force) if __name__ == "__main__": raise SystemExit(main()) -
campaign-parse-manifest.py 4 KB
# /// script # requires-python = ">=3.9" # dependencies = [] # /// """Campaign Parse Manifest — deterministic parse of a --manifest target list. step-01 (Setup) seeds a headless campaign's targets from a plain-text manifest. Parsing that format by hand risks silently dropping a malformed line — exactly the kind of mechanical, all-or-nothing work that belongs in a script. This parser is the single source of truth for the format documented in SKILL.md "On Activation". Format (one target per line): name,repo_url,tier,pin[;dep1,dep2,...] - `pin` may be empty (latest): `name,repo_url,tier,` or `name,repo_url,tier` - a trailing `;`-segment lists depends_on names (comma-separated) - blank lines and lines starting with `#` are skipped - `tier` must be `A` or `B` CLI: uv run campaign-parse-manifest.py <path/to/manifest.txt> cat manifest.txt | uv run campaign-parse-manifest.py - Output (JSON on stdout): {"targets": [{"name","repo_url","tier","pin","depends_on"}], "errors": [{"line","message"}]} Exit codes: 0 parsed cleanly (no errors) 1 one or more malformed lines (errors[] populated; targets[] omits bad lines) 2 file error (not found / unreadable) """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any, Dict, List def parse_manifest_text(text: str) -> Dict[str, Any]: targets: List[Dict[str, Any]] = [] errors: List[Dict[str, Any]] = [] seen: set = set() for lineno, raw in enumerate(text.splitlines(), start=1): line = raw.strip() if not line or line.startswith("#"): continue body, sep, deps_part = line.partition(";") fields = [f.strip() for f in body.split(",")] if len(fields) < 3: errors.append({"line": lineno, "message": f"expected `name,repo_url,tier[,pin]`, got {len(fields)} field(s)"}) continue name, repo_url, tier = fields[0], fields[1], fields[2] pin = fields[3] if len(fields) >= 4 and fields[3] != "" else None if not name: errors.append({"line": lineno, "message": "empty `name`"}) continue if not repo_url: errors.append({"line": lineno, "message": f"`{name}` has empty `repo_url`"}) continue if tier not in ("A", "B"): errors.append({"line": lineno, "message": f"`{name}` has invalid tier `{tier}` (must be A or B)"}) continue if name in seen: errors.append({"line": lineno, "message": f"duplicate target name `{name}`"}) continue seen.add(name) depends_on = [d.strip() for d in deps_part.split(",") if d.strip()] if sep else [] targets.append( {"name": name, "repo_url": repo_url, "tier": tier, "pin": pin, "depends_on": depends_on} ) return {"targets": targets, "errors": errors} def run(path: str) -> int: if path == "-": text = sys.stdin.read() else: p = Path(path) if not p.is_file(): json.dump({"error": f"Manifest not found: {path}", "code": "MANIFEST_NOT_FOUND"}, sys.stderr) sys.stderr.write("\n") return 2 try: text = p.read_text(encoding="utf-8") except OSError as exc: json.dump({"error": f"Manifest unreadable: {exc}", "code": "MANIFEST_READ_ERROR"}, sys.stderr) sys.stderr.write("\n") return 2 result = parse_manifest_text(text) json.dump(result, sys.stdout, separators=(",", ":")) sys.stdout.write("\n") return 1 if result["errors"] else 0 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="campaign-parse-manifest", description="Parse a --manifest target list into structured targets.", ) parser.add_argument("path", help="path to manifest text file, or `-` for stdin") args = parser.parse_args(argv) return run(args.path) if __name__ == "__main__": raise SystemExit(main()) -
campaign-provenance.py 8.8 KB
# /// script # requires-python = ">=3.9" # dependencies = ["pyyaml"] # /// """Campaign Provenance — verify repo access and record commit SHAs for all targets. Replaces the step-04 prose that asked the LLM to string-munge each repo_url ("handle trailing .git or slashes"), run `gh repo view` + `gh api commits/{ref}` per target, and aggregate failures across 15+ targets in-context. All of that is deterministic; doing it by hand is both token-expensive and a fragile-parse risk. This script owns the parse, the gh calls, and the aggregation, and — when every (or nearly every) target fails the same way — collapses the wall of near-identical errors into a single actionable root-cause hint instead of N independent failures. For each skill it resolves `{owner}/{repo}` from the brief's repo_url, picks the ref (the skill's pin, or the repo default branch), verifies access, and records the commit SHA. CLI: uv run campaign-provenance.py --state-file <path> --brief-file <path> Output (JSON on stdout): { "results": [ {"name": "...", "repo_url": "...", "owner": "...", "repo": "...", "ref": "...", "commit_sha": "..." | null, "status": "accessible" | "inaccessible", "error": "..." | null} ], "all_accessible": bool, "inaccessible_count": N, "systemic_hint": "..." | null } Exit codes: 0 all targets accessible 1 one or more targets inaccessible 2 error (missing files, bad YAML, gh not installed) """ from __future__ import annotations import argparse import json import re import shutil import subprocess # noqa: S404 — invoking the user's authenticated `gh` CLI is the point import sys from pathlib import Path from typing import Any, Callable, Dict, List, Optional, Tuple import yaml # A command runner returns (returncode, stdout, stderr). Injectable for tests. Runner = Callable[[List[str]], Tuple[int, str, str]] def _emit_error(message: str, code: str) -> None: json.dump({"error": message, "code": code}, sys.stderr) sys.stderr.write("\n") def _load_yaml(path: Path) -> Any: with open(path, encoding="utf-8") as f: return yaml.safe_load(f) def parse_owner_repo(repo_url: str) -> Optional[Tuple[str, str]]: """Extract (owner, repo) from a GitHub URL or `owner/repo` shorthand. Tolerates trailing `.git`, trailing slashes, `git@` SSH form, and a bare `owner/repo`. Returns None when no owner/repo pair can be recovered. """ if not repo_url or not isinstance(repo_url, str): return None url = repo_url.strip().rstrip("/") if url.endswith(".git"): url = url[:-4] # git@github.com:owner/repo ssh = re.match(r"^git@[^:]+:(?P<owner>[^/]+)/(?P<repo>[^/]+)$", url) if ssh: return ssh.group("owner"), ssh.group("repo") # https://host/owner/repo (take the last two path segments) https = re.match(r"^[a-zA-Z]+://[^/]+/(?P<rest>.+)$", url) rest = https.group("rest") if https else url parts = [p for p in rest.split("/") if p] if len(parts) >= 2: return parts[-2], parts[-1] return None def _default_runner(args: List[str]) -> Tuple[int, str, str]: proc = subprocess.run(args, capture_output=True, text=True) # noqa: S603 return proc.returncode, proc.stdout, proc.stderr def _classify_error(stderr: str) -> str: """Bucket a gh failure so systemic root causes can be detected.""" low = stderr.lower() if "authentication" in low or "gh auth" in low or "not logged" in low or "401" in low: return "auth" if "could not resolve" in low or "network" in low or "timeout" in low or "dial tcp" in low: return "network" if "rate limit" in low or "403" in low: return "rate-limit" if "not found" in low or "404" in low: return "not-found" return "other" _SYSTEMIC_HINTS = { "auth": "All targets failed authentication — run `gh auth status` / `gh auth login`, then `campaign resume`.", "network": "All targets failed with network errors — check connectivity, then `campaign resume`.", "rate-limit": "All targets hit GitHub rate limiting — wait for the limit to reset, then `campaign resume`.", } def run(state_file: str, brief_file: str, runner: Runner = _default_runner) -> int: state_path = Path(state_file) brief_path = Path(brief_file) if not state_path.is_file(): _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND") return 2 if not brief_path.is_file(): _emit_error(f"Brief file not found: {brief_file}", "BRIEF_NOT_FOUND") return 2 if shutil.which("gh") is None and runner is _default_runner: _emit_error("GitHub CLI `gh` not found on PATH", "GH_NOT_FOUND") return 2 try: state = _load_yaml(state_path) except Exception as exc: # noqa: BLE001 _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR") return 2 try: brief = _load_yaml(brief_path) except Exception as exc: # noqa: BLE001 _emit_error(f"Failed to parse brief file: {exc}", "BRIEF_PARSE_ERROR") return 2 skills = state.get("skills", []) if not isinstance(skills, list): _emit_error("State file 'skills' is not an array", "INVALID_STATE") return 2 targets = brief.get("targets", []) if not isinstance(targets, list): _emit_error("Brief file 'targets' is not an array", "INVALID_BRIEF") return 2 name_to_repo: Dict[str, str] = {t["name"]: t["repo_url"] for t in targets} results: List[Dict[str, Any]] = [] error_classes: List[str] = [] for skill in skills: name = skill["name"] repo_url = name_to_repo.get(name) record: Dict[str, Any] = { "name": name, "repo_url": repo_url, "owner": None, "repo": None, "ref": None, "commit_sha": None, "status": "inaccessible", "error": None, } if repo_url is None: record["error"] = f"Skill '{name}' has no repo_url in brief targets" error_classes.append("other") results.append(record) continue parsed = parse_owner_repo(repo_url) if parsed is None: record["error"] = f"Could not parse owner/repo from '{repo_url}'" error_classes.append("other") results.append(record) continue owner, repo = parsed record["owner"], record["repo"] = owner, repo rc, _out, err = runner(["gh", "repo", "view", f"{owner}/{repo}", "--json", "name"]) if rc != 0: record["error"] = err.strip() or "gh repo view failed" error_classes.append(_classify_error(err)) results.append(record) continue ref = skill.get("pin") if not ref: rc, out, err = runner( ["gh", "repo", "view", f"{owner}/{repo}", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"] ) if rc != 0: record["error"] = err.strip() or "could not resolve default branch" error_classes.append(_classify_error(err)) results.append(record) continue ref = out.strip() record["ref"] = ref rc, out, err = runner(["gh", "api", f"repos/{owner}/{repo}/commits/{ref}", "--jq", ".sha"]) if rc != 0: record["error"] = err.strip() or f"could not resolve commit for ref '{ref}'" error_classes.append(_classify_error(err)) results.append(record) continue record["commit_sha"] = out.strip() record["status"] = "accessible" results.append(record) inaccessible = [r for r in results if r["status"] != "accessible"] systemic_hint: Optional[str] = None if inaccessible and len(inaccessible) == len(results): # Every target failed — if they share a class, surface one root cause. distinct = set(error_classes) if len(distinct) == 1: systemic_hint = _SYSTEMIC_HINTS.get(next(iter(distinct))) output = { "results": results, "all_accessible": not inaccessible, "inaccessible_count": len(inaccessible), "systemic_hint": systemic_hint, } json.dump(output, sys.stdout, separators=(",", ":")) sys.stdout.write("\n") return 0 if not inaccessible else 1 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="campaign-provenance", description="Verify repo access and record commit SHAs for all campaign targets.", ) parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml") parser.add_argument("--brief-file", required=True, help="Path to campaign-brief.yaml") args = parser.parse_args(argv) return run(args.state_file, args.brief_file) if __name__ == "__main__": raise SystemExit(main()) -
campaign-render-batch.py 7 KB
# /// script # requires-python = ">=3.9" # dependencies = ["pyyaml"] # /// """Campaign Render Batch — build the QS `--batch` input file for Tier B skills. step-06 batches every Tier B skill through `skf-quick-skill --batch`. The batch input file is a machine-consumed cross-tool contract: one target per line in the exact single-target shape `skf-quick-skill` parses (see src/skf-quick-skill/references/batch-mode.md). Hand-building that file in prose is filter + join + reformat-structured-data — the same mechanical, all-or-nothing work campaign-parse-manifest.py owns for the inverse direction, where a silent format slip corrupts the whole run. This script is the single source of truth for the generation side so the line format lives in one place, aligned with the consumer's contract, instead of being re-derived in the step prose. What it does: - Filters `skills[]` for `tier == "B" && status == "pending"` (skips Tier A and any already completed/failed/skipped — resume-safe). - Looks up each skill's `repo_url` from the brief's `targets[]` (matched by name); repo URLs live only in the brief, never in the state schema. - Emits one line per skill in the CONSUMER's single-target shape: {repo_url} (pin is null → latest) {repo_url}@{pin} (skills[].pin is non-null) with optional ` language=<lang>` / ` scope=<path>` modifiers appended when the brief target carries a `language_hint`/`language` or `scope_hint`/`scope` hint. - No skill-name field, no bare pin token — that would not parse as a target. CLI: uv run campaign-render-batch.py --state-file <p> --brief-file <p> [-o <batchFile>] Output: - Batch text (one target per line) to {batchFile} via -o, else to stdout. - JSON summary {written, count, skipped_non_tierB, skipped_non_pending} to stderr. Exit codes: 0 batch file written (0+ targets) 2 state file missing / unreadable / bad YAML 8 brief missing / unreadable (missing-brief HALT), or a pending Tier B skill has no matching brief target with a repo_url (unmatched-target) """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any, Dict, List, Tuple import yaml def _err(message: str, code: str, exit_code: int, **extra: Any) -> int: payload: Dict[str, Any] = {"error": message, "code": code} payload.update(extra) json.dump(payload, sys.stderr) sys.stderr.write("\n") return exit_code def _target_line(repo_url: str, pin: Any, target: Dict[str, Any]) -> str: """Render one batch line in the consumer's single-target shape.""" line = repo_url if pin: line += f"@{pin}" lang = target.get("language_hint") or target.get("language") scope = target.get("scope_hint") or target.get("scope") if lang: line += f" language={lang}" if scope: line += f" scope={scope}" return line def build_batch(state: Dict[str, Any], brief: Dict[str, Any]) -> Tuple[List[str], Dict[str, Any]]: """Filter Tier B pending skills and render batch lines. Returns (lines, summary). `summary["unmatched"]` lists any pending Tier B skill names with no brief target / repo_url; the caller HALTs (exit 8) when it is non-empty. """ skills = state.get("skills") or [] targets: Dict[str, Dict[str, Any]] = { t.get("name"): t for t in (brief.get("targets") or []) if isinstance(t, dict) and t.get("name") } lines: List[str] = [] unmatched: List[str] = [] skipped_non_tierb = 0 skipped_non_pending = 0 for skill in skills: if not isinstance(skill, dict): continue if skill.get("tier") != "B": skipped_non_tierb += 1 continue if skill.get("status") != "pending": skipped_non_pending += 1 continue name = skill.get("name") target = targets.get(name) repo_url = (target or {}).get("repo_url") or "" if not repo_url: unmatched.append(name) continue lines.append(_target_line(repo_url, skill.get("pin"), target)) summary = { "count": len(lines), "skipped_non_tierB": skipped_non_tierb, "skipped_non_pending": skipped_non_pending, "unmatched": unmatched, } return lines, summary def _load_yaml_mapping(path: str) -> Dict[str, Any]: data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) if data is None: return {} if not isinstance(data, dict): raise ValueError("top-level document is not a mapping") return data def run(state_file: str, brief_file: str, output: str | None) -> int: # State problems are plain file/parse errors (exit 2). if not Path(state_file).is_file(): return _err(f"State file not found: {state_file}", "STATE_NOT_FOUND", 2) try: state = _load_yaml_mapping(state_file) except (yaml.YAMLError, ValueError) as exc: return _err(f"Failed to parse state YAML: {exc}", "STATE_PARSE_ERROR", 2) # Brief problems preserve step-06 §4's missing-brief HALT (exit 8): repo_urls # live only in the brief, so a missing/unreadable/corrupt brief is fatal here. if not Path(brief_file).is_file(): return _err(f"Brief file not found: {brief_file}", "missing-brief", 8) try: brief = _load_yaml_mapping(brief_file) except OSError as exc: return _err(f"Brief unreadable: {exc}", "missing-brief", 8) except (yaml.YAMLError, ValueError) as exc: return _err(f"Brief unparseable: {exc}", "missing-brief", 8) lines, summary = build_batch(state, brief) unmatched = summary.pop("unmatched") if unmatched: return _err( "No matching brief target (repo_url) for pending Tier B skill(s): " + ", ".join(str(n) for n in unmatched), "unmatched-target", 8, skills=unmatched, ) batch_text = "\n".join(lines) if batch_text: batch_text += "\n" if output: Path(output).write_text(batch_text, encoding="utf-8") summary["written"] = output else: sys.stdout.write(batch_text) summary["written"] = "<stdout>" ordered = { "written": summary["written"], "count": summary["count"], "skipped_non_tierB": summary["skipped_non_tierB"], "skipped_non_pending": summary["skipped_non_pending"], } json.dump(ordered, sys.stderr) sys.stderr.write("\n") return 0 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="campaign-render-batch", description="Build the QS --batch input file for pending Tier B skills.", ) parser.add_argument("--state-file", required=True) parser.add_argument("--brief-file", required=True) parser.add_argument("-o", "--output", dest="output", help="batch file path (default: stdout)") args = parser.parse_args(argv) return run(args.state_file, args.brief_file, args.output) if __name__ == "__main__": raise SystemExit(main()) -
campaign-render-kickoff.py 5.8 KB
# /// script # requires-python = ">=3.9" # dependencies = ["pyyaml"] # /// """Campaign Render Kickoff — fill the mechanical kickoff-template placeholders. step-05 emits a kickoff message per Tier-A skill. Most of its placeholders are direct field copies from state + brief (campaign name, stage, quality gate, skill identity, repo, pin, commit, the dependency-status table, the workaround list) — mechanical substitution that an LLM should not hand-perform 15× per campaign. This script renders those deterministically and leaves the three judgment slots untouched for the LLM to fill in context: {{brief_summary}} — concise summary of the brief target entry {{persistent_facts}} — campaign-wide facts resolved in On Activation {{directive_content}} — raw directive file content CLI: uv run campaign-render-kickoff.py --state-file <p> --brief-file <p> \ --skill <name> --template <p> [--workarounds '<json-list>'] Output: the rendered kickoff markdown on stdout (judgment slots preserved). Exit codes: 0 rendered 2 error (missing file, bad YAML, skill/target not found, bad --workarounds) """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any, Dict, List, Optional import yaml # Placeholders this script intentionally leaves for the LLM to fill. JUDGMENT_SLOTS = ("{{brief_summary}}", "{{persistent_facts}}", "{{directive_content}}") def _err(message: str, code: str) -> int: json.dump({"error": message, "code": code}, sys.stderr) sys.stderr.write("\n") return 2 def _quality_gate_summary(qg: Dict[str, Any]) -> str: return ( f"Hard: {qg.get('hard', 'N/A')} | " f"Soft: {qg.get('soft_target', 'N/A')} (fallback: {qg.get('soft_fallback', 'N/A')})" ) def _dependency_status_table(skill: Dict[str, Any], skill_map: Dict[str, Dict[str, Any]]) -> str: deps = skill.get("depends_on", []) or [] if not deps: return "No dependencies." rows = ["| Dependency | Status |", "|------------|--------|"] for dep in deps: status = skill_map.get(dep, {}).get("status", "unknown") rows.append(f"| {dep} | {status} |") return "\n".join(rows) def _workarounds_list(workarounds: List[str]) -> str: if not workarounds: return "None" return "\n".join(f"- {w}" for w in workarounds) def render_kickoff( state: Dict[str, Any], brief: Dict[str, Any], skill_name: str, template: str, workarounds: Optional[List[str]] = None, ) -> str: campaign = state.get("campaign", {}) skills = state.get("skills", []) skill_map = {s["name"]: s for s in skills} if skill_name not in skill_map: raise KeyError(f"Skill '{skill_name}' not found in state") skill = skill_map[skill_name] targets = {t["name"]: t for t in brief.get("targets", [])} repo_url = targets.get(skill_name, {}).get("repo_url", "") wa = workarounds if workarounds is not None else (skill.get("workarounds_applied", []) or []) mechanical = { "{{campaign_name}}": str(campaign.get("name", "")), "{{current_stage}}": str(campaign.get("current_stage", "")), "{{quality_gate_summary}}": _quality_gate_summary(campaign.get("quality_gate", {})), "{{skill_name}}": skill_name, "{{skill_tier}}": str(skill.get("tier", "")), "{{pin}}": skill.get("pin") or "latest", "{{commit_sha}}": skill.get("commit_sha") or "unknown", "{{repo_url}}": repo_url, "{{workarounds_list}}": _workarounds_list(wa), "{{dependency_status_table}}": _dependency_status_table(skill, skill_map), } out = template for key, value in mechanical.items(): out = out.replace(key, value) return out def run(state_file: str, brief_file: str, skill: str, template_file: str, workarounds_json: Optional[str]) -> int: for label, p in (("State", state_file), ("Brief", brief_file), ("Template", template_file)): if not Path(p).is_file(): return _err(f"{label} file not found: {p}", f"{label.upper()}_NOT_FOUND") try: state = yaml.safe_load(Path(state_file).read_text(encoding="utf-8")) brief = yaml.safe_load(Path(brief_file).read_text(encoding="utf-8")) except yaml.YAMLError as exc: return _err(f"Failed to parse YAML: {exc}", "PARSE_ERROR") template = Path(template_file).read_text(encoding="utf-8") workarounds: Optional[List[str]] = None if workarounds_json: try: workarounds = json.loads(workarounds_json) if not isinstance(workarounds, list): raise ValueError("not a list") except ValueError as exc: return _err(f"--workarounds must be a JSON list: {exc}", "BAD_WORKAROUNDS") try: rendered = render_kickoff(state, brief, skill, template, workarounds) except KeyError as exc: return _err(str(exc), "SKILL_NOT_FOUND") sys.stdout.write(rendered) if not rendered.endswith("\n"): sys.stdout.write("\n") return 0 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="campaign-render-kickoff", description="Render the mechanical placeholders in the campaign kickoff template.", ) parser.add_argument("--state-file", required=True) parser.add_argument("--brief-file", required=True) parser.add_argument("--skill", required=True, help="skill name (must exist in state)") parser.add_argument("--template", required=True, dest="template_file") parser.add_argument("--workarounds", dest="workarounds_json", help="JSON list of applied workarounds") args = parser.parse_args(argv) return run(args.state_file, args.brief_file, args.skill, args.template_file, args.workarounds_json) if __name__ == "__main__": raise SystemExit(main()) -
campaign-report.py 8.4 KB
# /// script # requires-python = ">=3.9" # dependencies = ["pyyaml"] # /// """Campaign Report — generate a markdown report from campaign state + template. CLI: uv run campaign-report.py \ --state-file <path> --template-file <path> --output-file <path> Output (JSON on stdout): {"status":"success","report_path":"...","skills_completed":N,"skills_failed":N, "quality_scores":{"skill":score,...},"duration":"..."} Exit codes: 0 success 2 error (missing file, bad YAML, template error) """ from __future__ import annotations import argparse import json import os import sys from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional import yaml def _emit_error(message: str, code: str) -> None: json.dump({"error": message, "code": code}, sys.stderr) sys.stderr.write("\n") def _load_yaml(path: Path) -> Any: with open(path, encoding="utf-8") as f: return yaml.safe_load(f) def _parse_iso(value: Optional[str]) -> Optional[datetime]: if not value: return None try: return datetime.fromisoformat(value) except (ValueError, TypeError): return None def _format_duration(start: Optional[datetime], end: Optional[datetime]) -> str: if not start or not end: return "N/A" delta = end - start total_seconds = int(delta.total_seconds()) if total_seconds < 0: return "N/A" hours, remainder = divmod(total_seconds, 3600) minutes, seconds = divmod(remainder, 60) if hours > 0: return f"{hours}h {minutes}m {seconds}s" if minutes > 0: return f"{minutes}m {seconds}s" return f"{seconds}s" def _compute_aggregates(state: Dict[str, Any]) -> Dict[str, Any]: campaign = state.get("campaign", {}) skills: List[Dict[str, Any]] = state.get("skills", []) started_at_str = campaign.get("started_at", "") last_updated_str = campaign.get("last_updated", "") started_at = _parse_iso(started_at_str) last_updated = _parse_iso(last_updated_str) completed = [s for s in skills if s.get("status") == "completed"] failed = [s for s in skills if s.get("status") == "failed"] skipped = [s for s in skills if s.get("status") == "skipped"] scores = [s["quality_score"] for s in completed if s.get("quality_score") is not None] quality_min = min(scores) if scores else 0 quality_max = max(scores) if scores else 0 quality_avg = round(sum(scores) / len(scores), 1) if scores else 0 all_workarounds: List[str] = [] skills_with_wa = 0 for s in skills: wa = s.get("workarounds_applied", []) or [] if wa: skills_with_wa += 1 all_workarounds.extend(wa) skills_table_rows = [] for s in skills: wa = s.get("workarounds_applied", []) or [] skills_table_rows.append( f"| {s.get('name', '')} " f"| {s.get('tier', '')} " f"| {s.get('status', '')} " f"| {s.get('quality_score', 'N/A')} " f"| {s.get('pin', 'N/A')} " f"| {len(wa)} |" ) quality_breakdown_rows = [] for s in completed: qs = s.get("quality_score") quality_breakdown_rows.append(f"- **{s['name']}**: {qs if qs is not None else 'N/A'}") if not quality_breakdown_rows: quality_breakdown_rows.append("No completed skills with quality scores.") if all_workarounds: workarounds_list_items = [f"- `{fp}`" for fp in all_workarounds] else: workarounds_list_items = ["No workarounds applied."] duration_table_rows = [] for s in skills: s_start = _parse_iso(s.get("started_at")) s_end = _parse_iso(s.get("completed_at")) s_start_str = s.get("started_at", "N/A") or "N/A" s_end_str = s.get("completed_at", "N/A") or "N/A" dur = _format_duration(s_start, s_end) duration_table_rows.append(f"| {s.get('name', '')} | {s_start_str} | {s_end_str} | {dur} |") failed_skipped_lines = [] if failed: failed_skipped_lines.append("### Failed Skills\n") for s in failed: failed_skipped_lines.append(f"- **{s['name']}** (Tier {s.get('tier', '?')})") if skipped: failed_skipped_lines.append("\n### Skipped Skills\n") for s in skipped: failed_skipped_lines.append(f"- **{s['name']}** (Tier {s.get('tier', '?')})") if not failed and not skipped: if skills: failed_skipped_lines.append("All skills completed successfully.") else: failed_skipped_lines.append("No skills in campaign.") quality_gate = campaign.get("quality_gate", {}) return { "campaign_name": campaign.get("name", ""), "started_at": started_at_str or "N/A", "completed_at": last_updated_str or "N/A", "duration": _format_duration(started_at, last_updated), "quality_gate_hard": quality_gate.get("hard", "N/A"), "quality_gate_soft_target": str(quality_gate.get("soft_target", "N/A")), "quality_gate_soft_fallback": str(quality_gate.get("soft_fallback", "N/A")), "skills_completed": str(len(completed)), "skills_failed": str(len(failed)), "skills_skipped": str(len(skipped)), "skills_table": "\n".join(skills_table_rows), "quality_min": str(quality_min), "quality_max": str(quality_max), "quality_avg": str(quality_avg), "quality_breakdown": "\n".join(quality_breakdown_rows), "total_workarounds": str(len(all_workarounds)), "skills_with_workarounds": str(skills_with_wa), "workarounds_list": "\n".join(workarounds_list_items), "duration_table": "\n".join(duration_table_rows), "failed_skipped_section": "\n".join(failed_skipped_lines), } def run(state_file: str, template_file: str, output_file: str) -> int: state_path = Path(state_file) template_path = Path(template_file) output_path = Path(output_file) if not state_path.is_file(): _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND") return 2 if not template_path.is_file(): _emit_error(f"Template file not found: {template_file}", "TEMPLATE_NOT_FOUND") return 2 try: state = _load_yaml(state_path) except Exception as exc: _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR") return 2 if not isinstance(state, dict): _emit_error("State file root is not a mapping", "INVALID_STATE") return 2 try: template = template_path.read_text(encoding="utf-8") except Exception as exc: _emit_error(f"Failed to read template file: {exc}", "TEMPLATE_READ_ERROR") return 2 try: aggregates = _compute_aggregates(state) except Exception as exc: _emit_error(f"Failed to compute report aggregates: {exc}", "AGGREGATE_ERROR") return 2 report = template for key, value in aggregates.items(): report = report.replace("{{" + key + "}}", value) output_path.parent.mkdir(parents=True, exist_ok=True) try: output_path.write_text(report, encoding="utf-8") except Exception as exc: _emit_error(f"Failed to write report: {exc}", "WRITE_ERROR") return 2 skills = state.get("skills", []) completed_count = sum(1 for s in skills if s.get("status") == "completed") failed_count = sum(1 for s in skills if s.get("status") == "failed") quality_scores = { s["name"]: s["quality_score"] for s in skills if s.get("status") == "completed" and s.get("quality_score") is not None } result = { "status": "success", "report_path": output_path.as_posix(), "skills_completed": completed_count, "skills_failed": failed_count, "quality_scores": quality_scores, "duration": aggregates["duration"], } json.dump(result, sys.stdout, separators=(",", ":")) sys.stdout.write("\n") return 0 def main() -> int: parser = argparse.ArgumentParser( description="Generate a campaign report from state and template.", ) parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml") parser.add_argument("--template-file", required=True, help="Path to campaign-report-template.md") parser.add_argument("--output-file", required=True, help="Path to write the generated report") args = parser.parse_args() return run(args.state_file, args.template_file, args.output_file) if __name__ == "__main__": raise SystemExit(main()) -
campaign-status.py 4.9 KB
# /// script # requires-python = ">=3.9" # dependencies = ["pyyaml"] # /// """Campaign Status — deterministic state summary + optional backup-drift verdict. Collapses two in-prompt determinism leaks into one helper the LLM calls instead of hand-deriving: - `campaign status` and the resume summary block tallied `skills[]` by status ("N completed / M total, K pending, ...") in-prompt over a 15+ skill array. - the `.bak` recovery path compared `primary` vs `backup` timestamps and stage numbers by hand (an ISO-8601 + int compare across two YAML files). Both are mechanical, one-correct-answer computations. This script owns them so identical state always yields identical output. CLI: uv run campaign-status.py --state-file <path> uv run campaign-status.py --state-file <path> --backup-file <path> Output (JSON on stdout): { "campaign_name": "...", "current_stage": 0, "last_updated": "...", "total": 0, "completed": 0, "pending": 0, "active": 0, "failed": 0, "skipped": 0, "backup_comparison": null | { "primary_behind": bool, "primary_last_updated": "...", "backup_last_updated": "...", "primary_stage": 0, "backup_stage": 0 } } Exit codes: 0 ok 2 error (state missing / unreadable / not a mapping) """ from __future__ import annotations import argparse import json import sys from datetime import datetime from pathlib import Path from typing import Any, Dict, Optional import yaml STATUSES = ("pending", "active", "completed", "failed", "skipped") def _err(message: str, code: str) -> int: json.dump({"error": message, "code": code}, sys.stderr) sys.stderr.write("\n") return 2 def _load_state(path: Path) -> Optional[Dict[str, Any]]: if not path.is_file(): return None try: data = yaml.safe_load(path.read_text(encoding="utf-8")) except (yaml.YAMLError, OSError): return None return data if isinstance(data, dict) else None def _parse_iso(value: Any) -> Optional[datetime]: if not isinstance(value, str) or not value: return None text = value[:-1] + "+00:00" if value.endswith("Z") else value try: return datetime.fromisoformat(text) except (ValueError, TypeError): return None def _counts(state: Dict[str, Any]) -> Dict[str, int]: skills = state.get("skills", []) or [] tally = {s: 0 for s in STATUSES} for skill in skills: status = skill.get("status") if status in tally: tally[status] += 1 tally["total"] = len(skills) return tally def _compare_backup(primary: Dict[str, Any], backup: Dict[str, Any]) -> Dict[str, Any]: p_c = primary.get("campaign", {}) or {} b_c = backup.get("campaign", {}) or {} p_updated = p_c.get("last_updated") b_updated = b_c.get("last_updated") p_stage = p_c.get("current_stage") b_stage = b_c.get("current_stage") stage_behind = ( isinstance(p_stage, int) and isinstance(b_stage, int) and p_stage < b_stage ) ts_behind = False p_ts = _parse_iso(p_updated) b_ts = _parse_iso(b_updated) if p_ts is not None and b_ts is not None: try: ts_behind = p_ts < b_ts except TypeError: # naive vs aware datetime — cannot order; defer to stage compare. ts_behind = False return { "primary_behind": bool(stage_behind or ts_behind), "primary_last_updated": p_updated, "backup_last_updated": b_updated, "primary_stage": p_stage, "backup_stage": b_stage, } def run(state_file: str, backup_file: Optional[str]) -> int: state = _load_state(Path(state_file)) if state is None: return _err(f"State not readable as a mapping: {state_file}", "STATE_UNREADABLE") campaign = state.get("campaign", {}) or {} result: Dict[str, Any] = { "campaign_name": campaign.get("name", ""), "current_stage": campaign.get("current_stage"), "last_updated": campaign.get("last_updated"), **_counts(state), "backup_comparison": None, } if backup_file: backup = _load_state(Path(backup_file)) if backup is not None: result["backup_comparison"] = _compare_backup(state, backup) json.dump(result, sys.stdout, separators=(",", ":"), default=str) sys.stdout.write("\n") return 0 def main(argv: Optional[list] = None) -> int: parser = argparse.ArgumentParser( prog="campaign-status", description="Summarize campaign state and (optionally) compare it to its backup.", ) parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml") parser.add_argument( "--backup-file", help="Path to _campaign-state.yaml.bak; when given, emit backup_comparison", ) args = parser.parse_args(argv) return run(args.state_file, args.backup_file) if __name__ == "__main__": raise SystemExit(main()) -
campaign-validate-pins.py 4.7 KB
# /// script # requires-python = ">=3.9" # dependencies = ["pyyaml"] # /// """Campaign Validate Pins — validate all version pins in a campaign state file. Campaign-specific wrapper around the shared skf-validate-pins.py module. Reads the campaign state and brief, validates every skill's pin against real GitHub releases/tags, and outputs consolidated JSON. CLI: uv run src/skf-campaign/scripts/campaign-validate-pins.py \ --state-file <path> --brief-file <path> Input: --state-file Path to _campaign-state.yaml --brief-file Path to campaign-brief.yaml Output (JSON on stdout): { "results": [ { "name": "skill-name", "status": "valid|invalid|resolved", "pin": "input-pin-or-null", "resolved_ref": "actual-tag-or-branch", "ref_type": "tag|branch|null", "version": "semver-or-null", "suggestions": [] } ], "all_valid": true, "invalid_count": 0, "resolved_count": 0 } Exit codes: 0 all valid/resolved 1 one or more invalid pins 2 error (missing files, bad YAML, gh unavailable) """ from __future__ import annotations import argparse import importlib.util import json import sys from pathlib import Path from typing import Any, Dict, List import yaml SHARED_SCRIPTS = Path(__file__).parent.parent.parent / "shared" / "scripts" VALIDATE_PINS_PATH = SHARED_SCRIPTS / "skf-validate-pins.py" def _load_validate_pin(): spec = importlib.util.spec_from_file_location("skf_validate_pins", VALIDATE_PINS_PATH) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod.validate_pin def _emit_error(message: str, code: str) -> None: json.dump({"error": message, "code": code}, sys.stderr) sys.stderr.write("\n") def _load_yaml(path: Path) -> Any: with open(path, encoding="utf-8") as f: return yaml.safe_load(f) def run(state_file: str, brief_file: str) -> int: state_path = Path(state_file) brief_path = Path(brief_file) if not state_path.is_file(): _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND") return 2 if not brief_path.is_file(): _emit_error(f"Brief file not found: {brief_file}", "BRIEF_NOT_FOUND") return 2 try: state = _load_yaml(state_path) except Exception as exc: _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR") return 2 try: brief = _load_yaml(brief_path) except Exception as exc: _emit_error(f"Failed to parse brief file: {exc}", "BRIEF_PARSE_ERROR") return 2 skills = state.get("skills", []) if not isinstance(skills, list): _emit_error("State file 'skills' is not an array", "INVALID_STATE") return 2 targets = brief.get("targets", []) if not isinstance(targets, list): _emit_error("Brief file 'targets' is not an array", "INVALID_BRIEF") return 2 name_to_repo: Dict[str, str] = {} for target in targets: name_to_repo[target["name"]] = target["repo_url"] if not VALIDATE_PINS_PATH.is_file(): _emit_error( f"Shared module not found: {VALIDATE_PINS_PATH.as_posix()}", "SHARED_MODULE_NOT_FOUND", ) return 2 validate_pin = _load_validate_pin() results: List[Dict[str, Any]] = [] invalid_count = 0 resolved_count = 0 for skill in skills: skill_name = skill["name"] repo_url = name_to_repo.get(skill_name) if repo_url is None: _emit_error( f"Skill '{skill_name}' not found in brief targets", "SKILL_NOT_IN_BRIEF", ) return 2 pin = skill.get("pin") result = validate_pin(repo_url, pin=pin) result["name"] = skill_name if result["status"] == "invalid": invalid_count += 1 elif result["status"] == "resolved": resolved_count += 1 results.append(result) all_valid = invalid_count == 0 output = { "results": results, "all_valid": all_valid, "invalid_count": invalid_count, "resolved_count": resolved_count, } json.dump(output, sys.stdout, separators=(",", ":")) sys.stdout.write("\n") if not all_valid: return 1 return 0 def main() -> int: parser = argparse.ArgumentParser( description="Validate all version pins in a campaign state file.", ) parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml") parser.add_argument("--brief-file", required=True, help="Path to campaign-brief.yaml") args = parser.parse_args() return run(args.state_file, args.brief_file) if __name__ == "__main__": raise SystemExit(main()) -
campaign-validate-state.py 6.6 KB
# /// script # requires-python = ">=3.9" # dependencies = ["pyyaml", "jsonschema>=4.0"] # /// """Campaign Validate State — schema check for _campaign-state.yaml on disk. Replaces the per-step "mentally validate the loaded state against the schema" prose with a deterministic check. Every campaign step that loads state runs this once on entry instead of asking the LLM to validate a draft-07 schema (nested objects, enums, additionalProperties:false, date-time) by hand — the exact check an LLM does unreliably and which, when wrong, silently corrupts a multi-session campaign. Loads the campaign state YAML, validates it against `assets/campaign-state-schema.json` (resolved relative to this script unless `--schema-file` overrides), and emits skill-friendly error records the calling step can forward verbatim. CLI: uv run campaign-validate-state.py --state-file <path> uv run campaign-validate-state.py --state-file <path> --schema-file <path> Output (JSON on stdout): { "valid": bool, "errors": [{"field": "campaign.current_stage", "message": "..."}, ...], "halt_reason": "state-missing" | "state-malformed" | "state-invalid" | null } Exit codes: 0 valid (errors empty) 1 invalid (schema violations) OR file/yaml load failed 2 configuration error (schema file missing or unreadable) """ from __future__ import annotations import argparse import datetime import json import sys from pathlib import Path from typing import Any import yaml from jsonschema import Draft7Validator DEFAULT_SCHEMA_PATH = Path(__file__).resolve().parent.parent / "assets" / "campaign-state-schema.json" def _emit(envelope: dict) -> None: json.dump(envelope, sys.stdout, separators=(",", ":"), default=str) sys.stdout.write("\n") def _load_yaml_text(text: str) -> tuple[Any, str | None]: try: data = yaml.safe_load(text) except yaml.YAMLError as exc: return None, f"State is not valid YAML: {exc}" if data is None: return None, "State file is empty" if not isinstance(data, dict): return None, f"State root must be a YAML mapping; got {type(data).__name__}" return data, None def _field_path(error_path) -> str: parts: list[str] = [] for p in error_path: if isinstance(p, int): parts.append(f"[{p}]") else: parts.append(f".{p}" if parts else str(p)) return "".join(parts) or "(root)" def _translate(err) -> dict: field = _field_path(err.absolute_path) validator = err.validator inst = err.instance if validator == "required": missing = err.message.split("'")[1] if "'" in err.message else "(unknown)" return { "field": missing, "message": f"State validation failed: missing required field `{missing}`.", } if validator == "enum": return { "field": field, "message": ( f"State validation failed: `{field}` value `{inst}` is not one of " f"{err.validator_value}." ), } if validator == "type": if isinstance(inst, datetime.date) and "string" in str(err.validator_value): return { "field": field, "message": ( f"State validation failed: `{field}` parsed as a YAML date, not a " f"string. Quote it (e.g. `'2026-05-01T00:00:00Z'`)." ), } return { "field": field, "message": ( f"State validation failed: `{field}` has type `{type(inst).__name__}`, " f"expected `{err.validator_value}`." ), } if validator == "additionalProperties": return { "field": field, "message": f"State validation failed: `{field}` — {err.message}", } if validator in ("minimum", "maximum"): return { "field": field, "message": f"State validation failed: `{field}` value `{inst}` violates {validator} `{err.validator_value}`.", } return { "field": field, "message": f"State validation failed: `{field}` — {err.message}", } def validate_state(state: dict, schema: dict) -> dict: validator = Draft7Validator(schema) errors = [ _translate(err) for err in sorted(validator.iter_errors(state), key=lambda e: list(e.absolute_path)) ] return {"valid": not errors, "errors": errors} def run(state_file: str, schema_file: str | None = None) -> int: schema_path = Path(schema_file) if schema_file else DEFAULT_SCHEMA_PATH if not schema_path.is_file(): _emit( { "valid": False, "errors": [{"field": "(schema)", "message": f"Schema not found at `{schema_path}`."}], "halt_reason": "state-invalid", } ) return 2 try: schema = json.loads(schema_path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: _emit( { "valid": False, "errors": [{"field": "(schema)", "message": f"Schema unreadable: {exc}"}], "halt_reason": "state-invalid", } ) return 2 state_path = Path(state_file) if not state_path.is_file(): _emit( { "valid": False, "errors": [{"field": "(file)", "message": f"State not found at `{state_path}`."}], "halt_reason": "state-missing", } ) return 1 state, load_err = _load_yaml_text(state_path.read_text(encoding="utf-8")) if load_err is not None: _emit( { "valid": False, "errors": [{"field": "(file)", "message": load_err}], "halt_reason": "state-malformed", } ) return 1 result = validate_state(state, schema) if not result["valid"]: _emit({**result, "halt_reason": "state-invalid"}) return 1 _emit({**result, "halt_reason": None}) return 0 def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="campaign-validate-state", description="Validate _campaign-state.yaml against the campaign state schema.", ) parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml") parser.add_argument( "--schema-file", help="Path to campaign-state-schema.json (defaults to the bundled schema)", ) args = parser.parse_args(argv) return run(args.state_file, args.schema_file) if __name__ == "__main__": raise SystemExit(main())
-
-
templates
-
campaign-brief-template.yaml 1.1 KB
# Machine-generated campaign brief — resume context for fresh sessions # Populated by step-01-setup.md from operator inputs # Campaign identifier campaign_name: "" # ISO-8601 timestamp of campaign creation created_at: "" # Target skills for this campaign run targets: [] # Each target entry: # name: "" # repo_url: "" # tier: "A" # A (full pipeline) or B (batch) # pin: null # version pin, or null for latest # depends_on: [] # list of skill names this target depends on # Quality thresholds applied to all skills in this campaign quality_gate: hard: "zero-critical-high" soft_target: 90 soft_fallback: 80 # Where health findings are routed: "local" (project-local) or "improvement" (shared queue) health_findings_queue: "local" # Optional path to the architecture document the verify (Stage 7) and refine # (Stage 8) stages consume. Empty string means those stages discover it at # runtime (docs/architecture.md, then _bmad-output/planning-artifacts/architecture.md). architecture_doc_path: "" # Operator-provided context or directives for this campaign notes: "" -
campaign-report-template.md 1.4 KB
# Campaign Report — {{campaign_name}} ## Campaign Summary | Field | Value | |-------|-------| | **Campaign** | {{campaign_name}} | | **Started** | {{started_at}} | | **Completed** | {{completed_at}} | | **Duration** | {{duration}} | | **Quality Gate (Hard)** | {{quality_gate_hard}} | | **Quality Gate (Soft Target)** | {{quality_gate_soft_target}} | | **Quality Gate (Soft Fallback)** | {{quality_gate_soft_fallback}} | | **Skills Completed** | {{skills_completed}} | | **Skills Failed** | {{skills_failed}} | | **Skills Skipped** | {{skills_skipped}} | ## Skills Overview | Name | Tier | Status | Quality Score | Pin | Workarounds | |------|------|--------|---------------|-----|-------------| {{skills_table}} ## Quality Scores | Metric | Value | |--------|-------| | **Minimum** | {{quality_min}} | | **Maximum** | {{quality_max}} | | **Average** | {{quality_avg}} | ### Per-Skill Breakdown {{quality_breakdown}} ## Findings Summary - **Total workarounds applied:** {{total_workarounds}} - **Skills with workarounds:** {{skills_with_workarounds}} - **Doc-rot corrections:** tracked per-skill in health-check findings (not aggregated in campaign state) ## Workarounds Applied {{workarounds_list}} ## Duration Breakdown | Skill | Started | Completed | Duration | |-------|---------|-----------|----------| {{duration_table}} ## Failed / Skipped Skills {{failed_skipped_section}} -
kickoff-template.md 950 B
# Skill Kickoff — {{skill_name}} ## Campaign Context - **Campaign:** {{campaign_name}} - **Current Stage:** {{current_stage}} - **Quality Gate:** {{quality_gate_summary}} ## Skill Identity - **Skill:** {{skill_name}} - **Tier:** {{skill_tier}} - **Repository:** {{repo_url}} - **Pin:** {{pin}} - **Commit:** {{commit_sha}} ## Brief Summary {{brief_summary}} ## Campaign Facts {{persistent_facts}} ## Dependency State {{dependency_status_table}} ## Standing Directive {{directive_content}} ## Workarounds Applied {{workarounds_list}} ## Pipeline Instructions Execute the standard forge pipeline for **{{skill_name}}**: 1. **AN** (Analyze) — scope and source intelligence 2. **BS** (Brief Synthesis) — generate or validate the skill brief 3. **CS** (Compile Skill) — compile the SKILL.md artifact 4. **TS** (Test Skill) — run health-check validation **Parameters:** - Pin: {{pin}} - Quality target: {{quality_gate_summary}}
-
-
customize.toml 2.9 KB
# DO NOT EDIT -- overwritten on every update. # # Workflow customization surface for skf-campaign. # Team overrides: _bmad/custom/skf-campaign.toml (under {project-root}) # Personal overrides: _bmad/custom/skf-campaign.user.toml (under {project-root}) [workflow] # --- Configurable below. Overrides merge per BMad structural rules: --- # scalars: override wins • arrays (persistent_facts, activation_steps_*): append # arrays-of-tables with `code`/`id`: replace matching items, append new ones. # Steps to run before the standard activation (config load, customization # resolve). Overrides append. Use for org-wide pre-flight checks (auth, # network, compliance) that must precede any orchestration work. activation_steps_prepend = [] # Steps to run after activation but before the first stage executes. # Overrides append. Use for context loads or banner customization that # should run once activation completes successfully. activation_steps_append = [] # Persistent facts the workflow keeps in mind for the whole campaign # (house style, naming conventions, quality guardrails). These are injected # into every per-skill kickoff, so project-scoped house facts propagate to # the whole campaign. Overrides append. # # Each entry is either: # - a literal sentence, e.g. "All skills must cite their upstream source." # - a file reference prefixed with `file:`, e.g. # "file:{project-root}/docs/skill-style.md" (globs supported; file # contents are loaded and treated as facts). persistent_facts = [ "file:{project-root}/**/project-context.md", ] # --- Workspace path --- # # Where the campaign keeps its state, backup, brief, batch input, archive, # and decision log. Empty string = use the bundled default # `{forge_data_folder}/_campaign`. Override to relocate campaign artifacts # (e.g. a shared volume) without editing every step file. Resolved once in # On Activation as {campaignWorkspacePath}. campaign_workspace_path = "" # --- Quality gate --- # # Campaign-wide quality bar applied to every skill. Scalars override the # bundled defaults; the per-campaign brief and any directive `## Quality # Overrides` still take precedence at runtime. Override here to change the # org-wide default without forking the skill. quality_gate_hard = "zero-critical-high" quality_gate_soft_target = 90 quality_gate_soft_fallback = 80 # --- Optional template overrides --- # # Lift the canonical template paths so orgs can substitute house-style copies # without forking the skill. Empty string = use the bundled default under # `templates/`. report_template_path = "" kickoff_template_path = "" brief_template_path = "" # Optional post-completion hook. When non-empty, the workflow invokes: # <on_complete> --report-path=<path-to-campaign-report.md> # after the campaign report is finalized (step-11). Failures are logged to # the decision log but never fail the campaign. Empty = no-op. on_complete = "" -
manifest.yaml 584 B
code: CA name: skf-campaign description: "Campaign orchestration — multi-library skill production with dependency tracking" version: "2.0.0" trigger: campaign parent_module: skf # Canonical internal filenames for the campaign workspace. Fixed contract (the # step chain references these names directly and test-skf-campaign-state guards # them against drift) — NOT an operator-override surface; customize.toml is the # only customization mechanism. config: state_file: "_campaign-state.yaml" backup_file: "_campaign-state.yaml.bak" directive_file: "_campaign-directive.md" -
SKILL.md 9.9 KB
--- name: skf-campaign description: Campaign orchestration — multi-library skill production with dependency tracking, file-based state, and resume. Use when the user asks to "run a campaign" or "orchestrate skills." --- # Campaign ## Overview Orchestrates the production of 15+ skills across multiple sessions by driving them through the full SKF pipeline (brief, generate, compile, test, export) in dependency order. Campaign sits atop the pipeline ladder: it sequences the workflows that produce skills rather than producing artifacts itself. File-based state (`_campaign-state.yaml`) survives context death, enabling resume from any point. ## Conventions - Bare paths (e.g. `references/step-01-setup.md`) resolve from the skill root. - `references/` holds the stage-chained step files plus reference specs (e.g. the `_campaign-directive.md` contract at `references/campaign-directive-spec.md`); `templates/`, `scripts/`, and `assets/` hold templates, deterministic helpers, and the state schema. - `{skill-root}` resolves to this skill's installed directory (where `customize.toml` lives). - `{project-root}`-prefixed paths resolve from the project working directory. - `{skill-name}` resolves to the skill directory's basename. ## Role You are a campaign orchestrator operating in Ferris's Management mode. You sequence workflows, track per-skill state, enforce quality gates, and ensure every skill reaches its target tier — while the individual pipeline workflows handle the actual artifact production. ## On Activation Run these steps once, in order, before dispatching to Mode Routing. 1. **Load config.** Read `{project-root}/_bmad/skf/config.yaml` and `{sidecar_path}/preferences.yaml` in one batched message (independent files). From config resolve `project_name`, `user_name`, `communication_language`, `document_output_language`, `skills_output_folder`, `forge_data_folder`, `sidecar_path`. From preferences resolve `headless_mode` (default false). If the config file is missing, fall back to `forge_data_folder = forge-data`. 2. **Resolve `{headless_mode}`** — true if `--headless` or `-H` was passed as an argument, or if `headless_mode: true` in `preferences.yaml`. Default: false. 3. **Resolve workflow customization.** Run: ```bash python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow ``` The script merges three layers (scalars override, arrays append): - `{skill-root}/customize.toml` — bundled defaults - `_bmad/custom/<skill-name>.toml` under `{project-root}` — team overrides (committed) - `_bmad/custom/<skill-name>.user.toml` under `{project-root}` — personal overrides (gitignored) If it fails or is missing, fall back to `{skill-root}/customize.toml` directly. Resolve each scalar now (so step files never repeat the conditional) and stash as workflow-context variables: - `{campaignWorkspacePath}` ← `workflow.campaign_workspace_path` if non-empty, else `{forge_data_folder}/_campaign` - `{qualityGateHard}` / `{qualityGateSoftTarget}` / `{qualityGateSoftFallback}` ← the `quality_gate_*` scalars (defaults `zero-critical-high` / `90` / `80`) - `{reportTemplatePath}` ← `workflow.report_template_path` if non-empty, else `templates/campaign-report-template.md` - `{kickoffTemplatePath}` ← `workflow.kickoff_template_path` if non-empty, else `templates/kickoff-template.md` - `{briefTemplatePath}` ← `workflow.brief_template_path` if non-empty, else `templates/campaign-brief-template.yaml` - `{onComplete}` ← `workflow.on_complete` (empty = no-op) Load `workflow.persistent_facts` (literal sentences and `file:` references, globs expanded) and keep them in mind for the whole campaign — they are injected into every per-skill kickoff. Run any `activation_steps_prepend` before step 1 and any `activation_steps_append` after this step. 4. **Parse CLI overrides** into the workflow context: | Flag | Effect | | --- | --- | | `--headless` / `-H` | Force `{headless_mode} = true` (see step 2). | | `--brief <file>` | Seed step-01 targets from a `campaign-brief.yaml` instead of interactive prompts. Implies `--headless`. | | `--manifest <file>` | Seed step-01 targets from a plain-text `name,repo_url,tier,pin` manifest. Implies `--headless`. | | `--from <skill>` | Resume override — see Mode Routing. | If `--brief` or `--manifest` is set, force `{headless_mode} = true` (log "headless: coerced by --brief/--manifest" if it was false). **`--manifest` format:** one `name,repo_url,tier,pin` target per line (empty `pin` = latest); a trailing `;dep1,dep2` segment sets `depends_on`; blank and `#` lines are skipped. A malformed line HALTs step-01 with the offending line numbers — never a partial target set. 5. **Dispatch** per Mode Routing below. ## Workflow Rules These rules apply to every step in this workflow: - State-first — write state to disk before chaining to the next step or workflow - Read-backup-modify-write for all state mutations (State Contract in `references/campaign-contracts.md`) - Validate `_campaign-state.yaml` on every load by running `uv run scripts/campaign-validate-state.py --state-file {stateFile}` and HALT (exit code 3, `invalid-state`) on non-zero — never hand-validate the schema - Zero memory dependency — campaign state is 100% recoverable from disk; never rely on conversation context for progress tracking - Treat a missing or unparseable `SKF_*_RESULT_JSON` envelope from any sub-skill as a sub-skill failure; never write partial state from an unparsed envelope - Append a one-line entry to the campaign decision log (`{campaignWorkspacePath}/_campaign-decision-log.md`, append-only) at every operator or auto-decision (skip/force, overwrite, export cancel/proceed, `.bak` recovery, user-cancel) so rationale survives compaction and resume - **Universal cancel affordance** — at any interactive gate between Setup and the Export gate, `cancel`/`exit`/`:q` triggers a HARD HALT with **exit code 12 (`user-cancelled`)**: log it and leave state intact and resumable. Exception: the Export gate's own `[C]ancel` stays exit code 11 (`export-cancelled`) — never also emit 12 there, so an automator's exit-code branch stays deterministic. These keywords count only as a response *to a prompt*; a skill or campaign named `cancel`/`exit` supplied as data is never treated as a cancel. - Always communicate in `{communication_language}` - If `{headless_mode}` is true, auto-proceed through confirmation gates with their default action and log each auto-decision - If `{headless_mode}` is true, emit a single-line JSON progress event to **stderr** at each step's entry, exit, and HARD HALT so schedulers stream live progress — event format in `references/campaign-contracts.md` (Headless Progress Events) ## Stages | # | Step | File | Auto-proceed | |---|------|------|--------------| | 0 | Setup | references/step-01-setup.md | Yes | | 1 | Strategy | references/step-02-strategy.md | Yes | | 2 | Pin Validation | references/step-03-pins.md | Yes | | 3 | Provenance | references/step-04-provenance.md | Yes | | 4 | Skill Loop | references/step-05-skill-loop.md | Yes | | 5 | Tier B Batch | references/step-06-batch.md | Yes | | 6 | Capstone | references/step-07-capstone.md | Yes | | 7 | Verification | references/step-08-verify.md | Yes | | 8 | Refinement | references/step-09-refine.md | Yes | | 9 | Export | references/step-10-export.md | No (write-gate HALT) | | 10 | Maintenance | references/step-11-maintenance.md | Yes | **Stage numbering:** step files are 1-indexed (`step-01` … `step-11`); `campaign.current_stage` in state is 0-indexed, so step-`NN` runs stage `NN − 1` (step-01 = stage 0, step-11 = stage 10). `references/step-resume.md` §3–§4 own how a resolved stage maps back to its step file on resume (including the `current_stage + 1` advance when no skill is active). ## Invocation Contract | Aspect | Detail | |--------|--------| | **Inputs** | `campaign` to start a new campaign; `campaign resume [--from=<skill>]` to resume from last active or specified skill; `campaign status` for a read-only progress summary | | **Outputs** | `_campaign-state.yaml` (state), `campaign-brief.yaml` (machine-generated brief), `campaign-report.md` (post-campaign summary), `_campaign-decision-log.md` (append-only rationale), `SKF_CAMPAIGN_RESULT_JSON` (headless envelope) — all under `{campaignWorkspacePath}` | ## Contracts Exit codes, the HARD-HALT error envelope, the read-backup-modify-write **State Contract**, the headless success envelope, and per-step progress events live in `references/campaign-contracts.md` — consult it when you HALT, mutate state, or emit headless output. ## Mode Routing On invocation: 1. **`campaign resume [--from=<skill>]`** — load `references/step-resume.md` (validates state, recovers from backup, chains to the right stage). `--from=<skill>` overrides the resume point to the named skill. 2. **`campaign`** (new, no existing state) — run from stage 0 (Setup). 3. **`campaign`** (state exists) — detect existing `{campaignWorkspacePath}/_campaign-state.yaml` and prompt **resume** (via `references/step-resume.md`) or **overwrite**. On overwrite, first archive the existing `_campaign-state.yaml` and `campaign-brief.yaml` to `{campaignWorkspacePath}/archive/{name}-{timestamp}/` and log it before chaining to step-01. In headless mode, default to **resume** (never silently clobber); archive-and-overwrite only when `--brief`/`--manifest` explicitly seeds a new campaign. 4. **`campaign status`** (read-only) — load `{campaignWorkspacePath}/_campaign-state.yaml`, validate it via `campaign-validate-state.py`, then run `uv run scripts/campaign-status.py --state-file {campaignWorkspacePath}/_campaign-state.yaml` and display its summary (campaign name, current stage, completed-vs-total, per-status counts) followed by the last ~15 lines of `{campaignWorkspacePath}/_campaign-decision-log.md` for the recent decision trail, then stop. No backup, no mutation, no chaining. Exit 0 (or 9 if the state is unrecoverable).
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.