bmad-module-skill-forge 1.9.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.nvmrc +1 -1
- package/README.md +10 -5
- package/docs/_data/pinned.yaml +2 -2
- package/docs/agents.md +11 -2
- package/docs/architecture.md +5 -4
- package/docs/bmad-synergy.md +30 -7
- package/docs/campaign.md +172 -0
- package/docs/examples.md +7 -3
- package/docs/forge-auto.md +90 -0
- package/docs/getting-started.md +9 -0
- package/docs/how-it-works.md +6 -4
- package/docs/index.md +4 -3
- package/docs/skill-model.md +1 -1
- package/docs/troubleshooting.md +17 -1
- package/docs/verifying-a-skill.md +2 -2
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +64 -24
- package/package.json +2 -2
- package/src/knowledge/skill-lifecycle.md +1 -1
- package/src/module-help.csv +4 -3
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +6 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- package/src/shared/scripts/skf-comention-pairs.py +350 -0
- package/src/shared/scripts/skf-count-tokens.py +222 -0
- package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +240 -4
- package/src/shared/scripts/skf-detect-language.py +82 -3
- package/src/shared/scripts/skf-detect-tools.py +105 -2
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- package/src/shared/scripts/skf-find-cycles.py +246 -0
- package/src/shared/scripts/skf-hash-content.py +487 -2
- package/src/shared/scripts/skf-language-corpora.py +100 -0
- package/src/shared/scripts/skf-manifest-ops.py +67 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
- package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
- package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
- package/src/shared/scripts/skf-shape-detect.py +576 -25
- package/src/shared/scripts/skf-shard-body.py +461 -0
- package/src/shared/scripts/skf-skill-inventory.py +139 -3
- package/src/shared/scripts/skf-structural-diff.py +307 -67
- package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
- package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
- package/src/shared/scripts/skf-validate-output.py +443 -10
- package/src/shared/scripts/skf-validate-pins.py +3 -3
- package/src/shared/scripts/skf-verify-no-trace.py +214 -0
- package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +17 -32
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/customize.toml +2 -0
- package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
- package/src/skf-analyze-source/references/continue.md +8 -13
- package/src/skf-analyze-source/references/generate-briefs.md +28 -12
- package/src/skf-analyze-source/references/headless-contract.md +33 -0
- package/src/skf-analyze-source/references/health-check.md +1 -2
- package/src/skf-analyze-source/references/identify-units.md +25 -36
- package/src/skf-analyze-source/references/init.md +12 -25
- package/src/skf-analyze-source/references/map-and-detect.md +8 -20
- package/src/skf-analyze-source/references/recommend.md +4 -14
- package/src/skf-analyze-source/references/scan-project.md +3 -11
- package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
- package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
- package/src/skf-audit-skill/SKILL.md +9 -6
- package/src/skf-audit-skill/customize.toml +2 -0
- package/src/skf-audit-skill/references/init.md +58 -32
- package/src/skf-audit-skill/references/re-index.md +15 -34
- package/src/skf-audit-skill/references/report.md +11 -58
- package/src/skf-audit-skill/references/semantic-diff.md +4 -22
- package/src/skf-audit-skill/references/severity-classify.md +53 -87
- package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
- package/src/skf-audit-skill/references/structural-diff.md +43 -68
- package/src/skf-brief-skill/SKILL.md +7 -36
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/customize.toml +14 -0
- package/src/skf-brief-skill/references/analyze-target.md +13 -18
- package/src/skf-brief-skill/references/confirm-brief.md +2 -15
- package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
- package/src/skf-brief-skill/references/gather-intent.md +27 -24
- package/src/skf-brief-skill/references/headless-args.md +1 -1
- package/src/skf-brief-skill/references/health-check.md +1 -22
- package/src/skf-brief-skill/references/invocation-contract.md +37 -0
- package/src/skf-brief-skill/references/scope-definition.md +6 -15
- package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
- package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +19 -13
- package/src/skf-campaign/SKILL.md +121 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +75 -0
- package/src/skf-campaign/manifest.yaml +15 -0
- package/src/skf-campaign/references/campaign-contracts.md +67 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +121 -0
- package/src/skf-campaign/references/step-02-strategy.md +102 -0
- package/src/skf-campaign/references/step-03-pins.md +56 -0
- package/src/skf-campaign/references/step-04-provenance.md +63 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
- package/src/skf-campaign/references/step-06-batch.md +88 -0
- package/src/skf-campaign/references/step-07-capstone.md +63 -0
- package/src/skf-campaign/references/step-08-verify.md +75 -0
- package/src/skf-campaign/references/step-09-refine.md +83 -0
- package/src/skf-campaign/references/step-10-export.md +106 -0
- package/src/skf-campaign/references/step-11-maintenance.md +84 -0
- package/src/skf-campaign/references/step-resume.md +115 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +244 -0
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
- package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -0
- package/src/skf-campaign/scripts/campaign-status.py +162 -0
- package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
- package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
- package/src/skf-campaign/templates/campaign-report-template.md +54 -0
- package/src/skf-campaign/templates/kickoff-template.md +48 -0
- package/src/skf-create-skill/SKILL.md +15 -4
- package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
- package/src/skf-create-skill/assets/skill-sections.md +9 -100
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
- package/src/skf-create-skill/customize.toml +13 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
- package/src/skf-create-skill/references/compile.md +37 -47
- package/src/skf-create-skill/references/component-extraction.md +8 -35
- package/src/skf-create-skill/references/ecosystem-check.md +9 -25
- package/src/skf-create-skill/references/enrich.md +4 -17
- package/src/skf-create-skill/references/extract.md +20 -27
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +10 -8
- package/src/skf-create-skill/references/generate-artifacts.md +20 -25
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/load-brief.md +16 -18
- package/src/skf-create-skill/references/report.md +27 -14
- package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
- package/src/skf-create-skill/references/step-auto-shard.md +52 -25
- package/src/skf-create-skill/references/step-doc-rot.md +33 -15
- package/src/skf-create-skill/references/step-doc-sources.md +13 -4
- package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
- package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
- package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
- package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
- package/src/skf-create-skill/references/validate.md +69 -29
- package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
- package/src/skf-create-stack-skill/SKILL.md +15 -9
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +10 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
- package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
- package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
- package/src/skf-create-stack-skill/references/generate-output.md +24 -75
- package/src/skf-create-stack-skill/references/init.md +9 -81
- package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
- package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
- package/src/skf-create-stack-skill/references/report.md +27 -62
- package/src/skf-create-stack-skill/references/validate.md +35 -31
- package/src/skf-drop-skill/SKILL.md +20 -23
- package/src/skf-drop-skill/customize.toml +10 -0
- package/src/skf-drop-skill/references/execute.md +67 -36
- package/src/skf-drop-skill/references/headless-contract.md +18 -0
- package/src/skf-drop-skill/references/health-check.md +1 -13
- package/src/skf-drop-skill/references/report.md +44 -10
- package/src/skf-drop-skill/references/select.md +93 -48
- package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
- package/src/skf-export-skill/SKILL.md +17 -14
- package/src/skf-export-skill/assets/managed-section-format.md +2 -5
- package/src/skf-export-skill/customize.toml +10 -6
- package/src/skf-export-skill/references/generate-snippet.md +21 -35
- package/src/skf-export-skill/references/load-skill.md +44 -42
- package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
- package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
- package/src/skf-export-skill/references/package.md +27 -33
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
- package/src/skf-export-skill/references/result-envelope.md +28 -0
- package/src/skf-export-skill/references/summary.md +11 -5
- package/src/skf-export-skill/references/token-report.md +42 -24
- package/src/skf-export-skill/references/update-context.md +32 -33
- package/src/skf-forger/SKILL.md +32 -39
- package/src/skf-forger/references/pipeline-mode.md +34 -0
- package/src/skf-forger/scripts/parse-pipeline.py +270 -0
- package/src/skf-quick-skill/SKILL.md +10 -60
- package/src/skf-quick-skill/customize.toml +9 -0
- package/src/skf-quick-skill/references/batch-mode.md +6 -4
- package/src/skf-quick-skill/references/compile.md +15 -52
- package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
- package/src/skf-quick-skill/references/finalize.md +12 -6
- package/src/skf-quick-skill/references/halt-contract.md +54 -0
- package/src/skf-quick-skill/references/health-check.md +3 -4
- package/src/skf-quick-skill/references/quick-extract.md +7 -18
- package/src/skf-quick-skill/references/registry-resolution.md +1 -23
- package/src/skf-quick-skill/references/resolve-target.md +27 -34
- package/src/skf-quick-skill/references/write-and-validate.md +10 -25
- package/src/skf-refine-architecture/SKILL.md +16 -8
- package/src/skf-refine-architecture/customize.toml +9 -0
- package/src/skf-refine-architecture/references/compile.md +10 -8
- package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
- package/src/skf-refine-architecture/references/health-check.md +1 -1
- package/src/skf-refine-architecture/references/improvements.md +9 -25
- package/src/skf-refine-architecture/references/init.md +9 -16
- package/src/skf-refine-architecture/references/issue-detection.md +10 -25
- package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
- package/src/skf-refine-architecture/references/report.md +14 -11
- package/src/skf-rename-skill/SKILL.md +11 -21
- package/src/skf-rename-skill/customize.toml +10 -0
- package/src/skf-rename-skill/references/execute.md +112 -82
- package/src/skf-rename-skill/references/exit-codes.md +12 -0
- package/src/skf-rename-skill/references/report.md +16 -8
- package/src/skf-rename-skill/references/select.md +69 -42
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
- package/src/skf-setup/SKILL.md +21 -13
- package/src/skf-setup/customize.toml +10 -0
- package/src/skf-setup/references/auto-index.md +11 -10
- package/src/skf-setup/references/ccc-index.md +14 -15
- package/src/skf-setup/references/detect-and-tier.md +10 -8
- package/src/skf-setup/references/report.md +26 -32
- package/src/skf-setup/references/tier-rules.md +2 -32
- package/src/skf-setup/references/write-config.md +9 -10
- package/src/skf-test-skill/SKILL.md +10 -8
- package/src/skf-test-skill/customize.toml +2 -0
- package/src/skf-test-skill/references/coherence-check.md +19 -31
- package/src/skf-test-skill/references/coverage-check.md +111 -67
- package/src/skf-test-skill/references/detect-mode.md +10 -41
- package/src/skf-test-skill/references/external-validators.md +11 -19
- package/src/skf-test-skill/references/init.md +39 -21
- package/src/skf-test-skill/references/migration-section-rules.md +4 -4
- package/src/skf-test-skill/references/report.md +60 -40
- package/src/skf-test-skill/references/score.md +32 -64
- package/src/skf-test-skill/references/scoring-rules.md +6 -62
- package/src/skf-test-skill/references/source-access-protocol.md +15 -5
- package/src/skf-test-skill/references/step-hard-gate.md +1 -3
- package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
- package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
- package/src/skf-test-skill/scripts/compute-score.py +135 -4
- package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
- package/src/skf-update-skill/SKILL.md +17 -5
- package/src/skf-update-skill/customize.toml +11 -6
- package/src/skf-update-skill/references/detect-changes.md +48 -36
- package/src/skf-update-skill/references/health-check.md +5 -11
- package/src/skf-update-skill/references/init.md +40 -32
- package/src/skf-update-skill/references/manual-section-rules.md +1 -2
- package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
- package/src/skf-update-skill/references/merge.md +13 -57
- package/src/skf-update-skill/references/re-extract.md +27 -25
- package/src/skf-update-skill/references/report.md +14 -7
- package/src/skf-update-skill/references/validate.md +32 -30
- package/src/skf-update-skill/references/write.md +86 -51
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
- package/src/skf-verify-stack/SKILL.md +11 -21
- package/src/skf-verify-stack/customize.toml +12 -3
- package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
- package/src/skf-verify-stack/references/coverage.md +21 -10
- package/src/skf-verify-stack/references/exit-codes.md +14 -0
- package/src/skf-verify-stack/references/health-check.md +1 -1
- package/src/skf-verify-stack/references/init.md +20 -25
- package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
- package/src/skf-verify-stack/references/integrations.md +44 -49
- package/src/skf-verify-stack/references/report.md +28 -34
- package/src/skf-verify-stack/references/requirements.md +3 -3
- package/src/skf-verify-stack/references/synthesize.md +36 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
- package/tools/cli/lib/ui.js +3 -2
- package/docs/deepwiki.md +0 -89
- package/src/skf-rename-skill/references/rebuild-context.md +0 -110
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Campaign Render Batch — build the QS `--batch` input file for Tier B skills.
|
|
6
|
+
|
|
7
|
+
step-06 batches every Tier B skill through `skf-quick-skill --batch`. The batch
|
|
8
|
+
input file is a machine-consumed cross-tool contract: one target per line in the
|
|
9
|
+
exact single-target shape `skf-quick-skill` parses (see
|
|
10
|
+
src/skf-quick-skill/references/batch-mode.md). Hand-building that file in prose
|
|
11
|
+
is filter + join + reformat-structured-data — the same mechanical, all-or-nothing
|
|
12
|
+
work campaign-parse-manifest.py owns for the inverse direction, where a silent
|
|
13
|
+
format slip corrupts the whole run. This script is the single source of truth for
|
|
14
|
+
the generation side so the line format lives in one place, aligned with the
|
|
15
|
+
consumer's contract, instead of being re-derived in the step prose.
|
|
16
|
+
|
|
17
|
+
What it does:
|
|
18
|
+
- Filters `skills[]` for `tier == "B" && status == "pending"` (skips Tier A and
|
|
19
|
+
any already completed/failed/skipped — resume-safe).
|
|
20
|
+
- Looks up each skill's `repo_url` from the brief's `targets[]` (matched by
|
|
21
|
+
name); repo URLs live only in the brief, never in the state schema.
|
|
22
|
+
- Emits one line per skill in the CONSUMER's single-target shape:
|
|
23
|
+
{repo_url} (pin is null → latest)
|
|
24
|
+
{repo_url}@{pin} (skills[].pin is non-null)
|
|
25
|
+
with optional ` language=<lang>` / ` scope=<path>` modifiers appended when the
|
|
26
|
+
brief target carries a `language_hint`/`language` or `scope_hint`/`scope` hint.
|
|
27
|
+
- No skill-name field, no bare pin token — that would not parse as a target.
|
|
28
|
+
|
|
29
|
+
CLI:
|
|
30
|
+
uv run campaign-render-batch.py --state-file <p> --brief-file <p> [-o <batchFile>]
|
|
31
|
+
|
|
32
|
+
Output:
|
|
33
|
+
- Batch text (one target per line) to {batchFile} via -o, else to stdout.
|
|
34
|
+
- JSON summary {written, count, skipped_non_tierB, skipped_non_pending} to stderr.
|
|
35
|
+
|
|
36
|
+
Exit codes:
|
|
37
|
+
0 batch file written (0+ targets)
|
|
38
|
+
2 state file missing / unreadable / bad YAML
|
|
39
|
+
8 brief missing / unreadable (missing-brief HALT), or a pending Tier B skill
|
|
40
|
+
has no matching brief target with a repo_url (unmatched-target)
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import json
|
|
47
|
+
import sys
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Any, Dict, List, Tuple
|
|
50
|
+
|
|
51
|
+
import yaml
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _err(message: str, code: str, exit_code: int, **extra: Any) -> int:
|
|
55
|
+
payload: Dict[str, Any] = {"error": message, "code": code}
|
|
56
|
+
payload.update(extra)
|
|
57
|
+
json.dump(payload, sys.stderr)
|
|
58
|
+
sys.stderr.write("\n")
|
|
59
|
+
return exit_code
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _target_line(repo_url: str, pin: Any, target: Dict[str, Any]) -> str:
|
|
63
|
+
"""Render one batch line in the consumer's single-target shape."""
|
|
64
|
+
line = repo_url
|
|
65
|
+
if pin:
|
|
66
|
+
line += f"@{pin}"
|
|
67
|
+
lang = target.get("language_hint") or target.get("language")
|
|
68
|
+
scope = target.get("scope_hint") or target.get("scope")
|
|
69
|
+
if lang:
|
|
70
|
+
line += f" language={lang}"
|
|
71
|
+
if scope:
|
|
72
|
+
line += f" scope={scope}"
|
|
73
|
+
return line
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def build_batch(state: Dict[str, Any], brief: Dict[str, Any]) -> Tuple[List[str], Dict[str, Any]]:
|
|
77
|
+
"""Filter Tier B pending skills and render batch lines.
|
|
78
|
+
|
|
79
|
+
Returns (lines, summary). `summary["unmatched"]` lists any pending Tier B
|
|
80
|
+
skill names with no brief target / repo_url; the caller HALTs (exit 8) when
|
|
81
|
+
it is non-empty.
|
|
82
|
+
"""
|
|
83
|
+
skills = state.get("skills") or []
|
|
84
|
+
targets: Dict[str, Dict[str, Any]] = {
|
|
85
|
+
t.get("name"): t
|
|
86
|
+
for t in (brief.get("targets") or [])
|
|
87
|
+
if isinstance(t, dict) and t.get("name")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
lines: List[str] = []
|
|
91
|
+
unmatched: List[str] = []
|
|
92
|
+
skipped_non_tierb = 0
|
|
93
|
+
skipped_non_pending = 0
|
|
94
|
+
|
|
95
|
+
for skill in skills:
|
|
96
|
+
if not isinstance(skill, dict):
|
|
97
|
+
continue
|
|
98
|
+
if skill.get("tier") != "B":
|
|
99
|
+
skipped_non_tierb += 1
|
|
100
|
+
continue
|
|
101
|
+
if skill.get("status") != "pending":
|
|
102
|
+
skipped_non_pending += 1
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
name = skill.get("name")
|
|
106
|
+
target = targets.get(name)
|
|
107
|
+
repo_url = (target or {}).get("repo_url") or ""
|
|
108
|
+
if not repo_url:
|
|
109
|
+
unmatched.append(name)
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
lines.append(_target_line(repo_url, skill.get("pin"), target))
|
|
113
|
+
|
|
114
|
+
summary = {
|
|
115
|
+
"count": len(lines),
|
|
116
|
+
"skipped_non_tierB": skipped_non_tierb,
|
|
117
|
+
"skipped_non_pending": skipped_non_pending,
|
|
118
|
+
"unmatched": unmatched,
|
|
119
|
+
}
|
|
120
|
+
return lines, summary
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _load_yaml_mapping(path: str) -> Dict[str, Any]:
|
|
124
|
+
data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
125
|
+
if data is None:
|
|
126
|
+
return {}
|
|
127
|
+
if not isinstance(data, dict):
|
|
128
|
+
raise ValueError("top-level document is not a mapping")
|
|
129
|
+
return data
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def run(state_file: str, brief_file: str, output: str | None) -> int:
|
|
133
|
+
# State problems are plain file/parse errors (exit 2).
|
|
134
|
+
if not Path(state_file).is_file():
|
|
135
|
+
return _err(f"State file not found: {state_file}", "STATE_NOT_FOUND", 2)
|
|
136
|
+
try:
|
|
137
|
+
state = _load_yaml_mapping(state_file)
|
|
138
|
+
except (yaml.YAMLError, ValueError) as exc:
|
|
139
|
+
return _err(f"Failed to parse state YAML: {exc}", "STATE_PARSE_ERROR", 2)
|
|
140
|
+
|
|
141
|
+
# Brief problems preserve step-06 §4's missing-brief HALT (exit 8): repo_urls
|
|
142
|
+
# live only in the brief, so a missing/unreadable/corrupt brief is fatal here.
|
|
143
|
+
if not Path(brief_file).is_file():
|
|
144
|
+
return _err(f"Brief file not found: {brief_file}", "missing-brief", 8)
|
|
145
|
+
try:
|
|
146
|
+
brief = _load_yaml_mapping(brief_file)
|
|
147
|
+
except OSError as exc:
|
|
148
|
+
return _err(f"Brief unreadable: {exc}", "missing-brief", 8)
|
|
149
|
+
except (yaml.YAMLError, ValueError) as exc:
|
|
150
|
+
return _err(f"Brief unparseable: {exc}", "missing-brief", 8)
|
|
151
|
+
|
|
152
|
+
lines, summary = build_batch(state, brief)
|
|
153
|
+
|
|
154
|
+
unmatched = summary.pop("unmatched")
|
|
155
|
+
if unmatched:
|
|
156
|
+
return _err(
|
|
157
|
+
"No matching brief target (repo_url) for pending Tier B skill(s): "
|
|
158
|
+
+ ", ".join(str(n) for n in unmatched),
|
|
159
|
+
"unmatched-target",
|
|
160
|
+
8,
|
|
161
|
+
skills=unmatched,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
batch_text = "\n".join(lines)
|
|
165
|
+
if batch_text:
|
|
166
|
+
batch_text += "\n"
|
|
167
|
+
|
|
168
|
+
if output:
|
|
169
|
+
Path(output).write_text(batch_text, encoding="utf-8")
|
|
170
|
+
summary["written"] = output
|
|
171
|
+
else:
|
|
172
|
+
sys.stdout.write(batch_text)
|
|
173
|
+
summary["written"] = "<stdout>"
|
|
174
|
+
|
|
175
|
+
ordered = {
|
|
176
|
+
"written": summary["written"],
|
|
177
|
+
"count": summary["count"],
|
|
178
|
+
"skipped_non_tierB": summary["skipped_non_tierB"],
|
|
179
|
+
"skipped_non_pending": summary["skipped_non_pending"],
|
|
180
|
+
}
|
|
181
|
+
json.dump(ordered, sys.stderr)
|
|
182
|
+
sys.stderr.write("\n")
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def main(argv: list[str] | None = None) -> int:
|
|
187
|
+
parser = argparse.ArgumentParser(
|
|
188
|
+
prog="campaign-render-batch",
|
|
189
|
+
description="Build the QS --batch input file for pending Tier B skills.",
|
|
190
|
+
)
|
|
191
|
+
parser.add_argument("--state-file", required=True)
|
|
192
|
+
parser.add_argument("--brief-file", required=True)
|
|
193
|
+
parser.add_argument("-o", "--output", dest="output", help="batch file path (default: stdout)")
|
|
194
|
+
args = parser.parse_args(argv)
|
|
195
|
+
return run(args.state_file, args.brief_file, args.output)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Campaign Render Kickoff — fill the mechanical kickoff-template placeholders.
|
|
6
|
+
|
|
7
|
+
step-05 emits a kickoff message per Tier-A skill. Most of its placeholders are
|
|
8
|
+
direct field copies from state + brief (campaign name, stage, quality gate,
|
|
9
|
+
skill identity, repo, pin, commit, the dependency-status table, the workaround
|
|
10
|
+
list) — mechanical substitution that an LLM should not hand-perform 15× per
|
|
11
|
+
campaign. This script renders those deterministically and leaves the three
|
|
12
|
+
judgment slots untouched for the LLM to fill in context:
|
|
13
|
+
|
|
14
|
+
{{brief_summary}} — concise summary of the brief target entry
|
|
15
|
+
{{persistent_facts}} — campaign-wide facts resolved in On Activation
|
|
16
|
+
{{directive_content}} — raw directive file content
|
|
17
|
+
|
|
18
|
+
CLI:
|
|
19
|
+
uv run campaign-render-kickoff.py --state-file <p> --brief-file <p> \
|
|
20
|
+
--skill <name> --template <p> [--workarounds '<json-list>']
|
|
21
|
+
|
|
22
|
+
Output: the rendered kickoff markdown on stdout (judgment slots preserved).
|
|
23
|
+
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 rendered
|
|
26
|
+
2 error (missing file, bad YAML, skill/target not found, bad --workarounds)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import json
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Dict, List, Optional
|
|
36
|
+
|
|
37
|
+
import yaml
|
|
38
|
+
|
|
39
|
+
# Placeholders this script intentionally leaves for the LLM to fill.
|
|
40
|
+
JUDGMENT_SLOTS = ("{{brief_summary}}", "{{persistent_facts}}", "{{directive_content}}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _err(message: str, code: str) -> int:
|
|
44
|
+
json.dump({"error": message, "code": code}, sys.stderr)
|
|
45
|
+
sys.stderr.write("\n")
|
|
46
|
+
return 2
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _quality_gate_summary(qg: Dict[str, Any]) -> str:
|
|
50
|
+
return (
|
|
51
|
+
f"Hard: {qg.get('hard', 'N/A')} | "
|
|
52
|
+
f"Soft: {qg.get('soft_target', 'N/A')} (fallback: {qg.get('soft_fallback', 'N/A')})"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _dependency_status_table(skill: Dict[str, Any], skill_map: Dict[str, Dict[str, Any]]) -> str:
|
|
57
|
+
deps = skill.get("depends_on", []) or []
|
|
58
|
+
if not deps:
|
|
59
|
+
return "No dependencies."
|
|
60
|
+
rows = ["| Dependency | Status |", "|------------|--------|"]
|
|
61
|
+
for dep in deps:
|
|
62
|
+
status = skill_map.get(dep, {}).get("status", "unknown")
|
|
63
|
+
rows.append(f"| {dep} | {status} |")
|
|
64
|
+
return "\n".join(rows)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _workarounds_list(workarounds: List[str]) -> str:
|
|
68
|
+
if not workarounds:
|
|
69
|
+
return "None"
|
|
70
|
+
return "\n".join(f"- {w}" for w in workarounds)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def render_kickoff(
|
|
74
|
+
state: Dict[str, Any],
|
|
75
|
+
brief: Dict[str, Any],
|
|
76
|
+
skill_name: str,
|
|
77
|
+
template: str,
|
|
78
|
+
workarounds: Optional[List[str]] = None,
|
|
79
|
+
) -> str:
|
|
80
|
+
campaign = state.get("campaign", {})
|
|
81
|
+
skills = state.get("skills", [])
|
|
82
|
+
skill_map = {s["name"]: s for s in skills}
|
|
83
|
+
if skill_name not in skill_map:
|
|
84
|
+
raise KeyError(f"Skill '{skill_name}' not found in state")
|
|
85
|
+
skill = skill_map[skill_name]
|
|
86
|
+
|
|
87
|
+
targets = {t["name"]: t for t in brief.get("targets", [])}
|
|
88
|
+
repo_url = targets.get(skill_name, {}).get("repo_url", "")
|
|
89
|
+
|
|
90
|
+
wa = workarounds if workarounds is not None else (skill.get("workarounds_applied", []) or [])
|
|
91
|
+
|
|
92
|
+
mechanical = {
|
|
93
|
+
"{{campaign_name}}": str(campaign.get("name", "")),
|
|
94
|
+
"{{current_stage}}": str(campaign.get("current_stage", "")),
|
|
95
|
+
"{{quality_gate_summary}}": _quality_gate_summary(campaign.get("quality_gate", {})),
|
|
96
|
+
"{{skill_name}}": skill_name,
|
|
97
|
+
"{{skill_tier}}": str(skill.get("tier", "")),
|
|
98
|
+
"{{pin}}": skill.get("pin") or "latest",
|
|
99
|
+
"{{commit_sha}}": skill.get("commit_sha") or "unknown",
|
|
100
|
+
"{{repo_url}}": repo_url,
|
|
101
|
+
"{{workarounds_list}}": _workarounds_list(wa),
|
|
102
|
+
"{{dependency_status_table}}": _dependency_status_table(skill, skill_map),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
out = template
|
|
106
|
+
for key, value in mechanical.items():
|
|
107
|
+
out = out.replace(key, value)
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def run(state_file: str, brief_file: str, skill: str, template_file: str, workarounds_json: Optional[str]) -> int:
|
|
112
|
+
for label, p in (("State", state_file), ("Brief", brief_file), ("Template", template_file)):
|
|
113
|
+
if not Path(p).is_file():
|
|
114
|
+
return _err(f"{label} file not found: {p}", f"{label.upper()}_NOT_FOUND")
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
state = yaml.safe_load(Path(state_file).read_text(encoding="utf-8"))
|
|
118
|
+
brief = yaml.safe_load(Path(brief_file).read_text(encoding="utf-8"))
|
|
119
|
+
except yaml.YAMLError as exc:
|
|
120
|
+
return _err(f"Failed to parse YAML: {exc}", "PARSE_ERROR")
|
|
121
|
+
template = Path(template_file).read_text(encoding="utf-8")
|
|
122
|
+
|
|
123
|
+
workarounds: Optional[List[str]] = None
|
|
124
|
+
if workarounds_json:
|
|
125
|
+
try:
|
|
126
|
+
workarounds = json.loads(workarounds_json)
|
|
127
|
+
if not isinstance(workarounds, list):
|
|
128
|
+
raise ValueError("not a list")
|
|
129
|
+
except ValueError as exc:
|
|
130
|
+
return _err(f"--workarounds must be a JSON list: {exc}", "BAD_WORKAROUNDS")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
rendered = render_kickoff(state, brief, skill, template, workarounds)
|
|
134
|
+
except KeyError as exc:
|
|
135
|
+
return _err(str(exc), "SKILL_NOT_FOUND")
|
|
136
|
+
|
|
137
|
+
sys.stdout.write(rendered)
|
|
138
|
+
if not rendered.endswith("\n"):
|
|
139
|
+
sys.stdout.write("\n")
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main(argv: list[str] | None = None) -> int:
|
|
144
|
+
parser = argparse.ArgumentParser(
|
|
145
|
+
prog="campaign-render-kickoff",
|
|
146
|
+
description="Render the mechanical placeholders in the campaign kickoff template.",
|
|
147
|
+
)
|
|
148
|
+
parser.add_argument("--state-file", required=True)
|
|
149
|
+
parser.add_argument("--brief-file", required=True)
|
|
150
|
+
parser.add_argument("--skill", required=True, help="skill name (must exist in state)")
|
|
151
|
+
parser.add_argument("--template", required=True, dest="template_file")
|
|
152
|
+
parser.add_argument("--workarounds", dest="workarounds_json", help="JSON list of applied workarounds")
|
|
153
|
+
args = parser.parse_args(argv)
|
|
154
|
+
return run(args.state_file, args.brief_file, args.skill, args.template_file, args.workarounds_json)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
if __name__ == "__main__":
|
|
158
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Campaign Report — generate a markdown report from campaign state + template.
|
|
6
|
+
|
|
7
|
+
CLI:
|
|
8
|
+
uv run campaign-report.py \
|
|
9
|
+
--state-file <path> --template-file <path> --output-file <path>
|
|
10
|
+
|
|
11
|
+
Output (JSON on stdout):
|
|
12
|
+
{"status":"success","report_path":"...","skills_completed":N,"skills_failed":N,
|
|
13
|
+
"quality_scores":{"skill":score,...},"duration":"..."}
|
|
14
|
+
|
|
15
|
+
Exit codes:
|
|
16
|
+
0 success
|
|
17
|
+
2 error (missing file, bad YAML, template error)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Dict, List, Optional
|
|
29
|
+
|
|
30
|
+
import yaml
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _emit_error(message: str, code: str) -> None:
|
|
34
|
+
json.dump({"error": message, "code": code}, sys.stderr)
|
|
35
|
+
sys.stderr.write("\n")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_yaml(path: Path) -> Any:
|
|
39
|
+
with open(path, encoding="utf-8") as f:
|
|
40
|
+
return yaml.safe_load(f)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_iso(value: Optional[str]) -> Optional[datetime]:
|
|
44
|
+
if not value:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return datetime.fromisoformat(value)
|
|
48
|
+
except (ValueError, TypeError):
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _format_duration(start: Optional[datetime], end: Optional[datetime]) -> str:
|
|
53
|
+
if not start or not end:
|
|
54
|
+
return "N/A"
|
|
55
|
+
delta = end - start
|
|
56
|
+
total_seconds = int(delta.total_seconds())
|
|
57
|
+
if total_seconds < 0:
|
|
58
|
+
return "N/A"
|
|
59
|
+
hours, remainder = divmod(total_seconds, 3600)
|
|
60
|
+
minutes, seconds = divmod(remainder, 60)
|
|
61
|
+
if hours > 0:
|
|
62
|
+
return f"{hours}h {minutes}m {seconds}s"
|
|
63
|
+
if minutes > 0:
|
|
64
|
+
return f"{minutes}m {seconds}s"
|
|
65
|
+
return f"{seconds}s"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _compute_aggregates(state: Dict[str, Any]) -> Dict[str, Any]:
|
|
69
|
+
campaign = state.get("campaign", {})
|
|
70
|
+
skills: List[Dict[str, Any]] = state.get("skills", [])
|
|
71
|
+
|
|
72
|
+
started_at_str = campaign.get("started_at", "")
|
|
73
|
+
last_updated_str = campaign.get("last_updated", "")
|
|
74
|
+
started_at = _parse_iso(started_at_str)
|
|
75
|
+
last_updated = _parse_iso(last_updated_str)
|
|
76
|
+
|
|
77
|
+
completed = [s for s in skills if s.get("status") == "completed"]
|
|
78
|
+
failed = [s for s in skills if s.get("status") == "failed"]
|
|
79
|
+
skipped = [s for s in skills if s.get("status") == "skipped"]
|
|
80
|
+
|
|
81
|
+
scores = [s["quality_score"] for s in completed if s.get("quality_score") is not None]
|
|
82
|
+
quality_min = min(scores) if scores else 0
|
|
83
|
+
quality_max = max(scores) if scores else 0
|
|
84
|
+
quality_avg = round(sum(scores) / len(scores), 1) if scores else 0
|
|
85
|
+
|
|
86
|
+
all_workarounds: List[str] = []
|
|
87
|
+
skills_with_wa = 0
|
|
88
|
+
for s in skills:
|
|
89
|
+
wa = s.get("workarounds_applied", []) or []
|
|
90
|
+
if wa:
|
|
91
|
+
skills_with_wa += 1
|
|
92
|
+
all_workarounds.extend(wa)
|
|
93
|
+
|
|
94
|
+
skills_table_rows = []
|
|
95
|
+
for s in skills:
|
|
96
|
+
wa = s.get("workarounds_applied", []) or []
|
|
97
|
+
skills_table_rows.append(
|
|
98
|
+
f"| {s.get('name', '')} "
|
|
99
|
+
f"| {s.get('tier', '')} "
|
|
100
|
+
f"| {s.get('status', '')} "
|
|
101
|
+
f"| {s.get('quality_score', 'N/A')} "
|
|
102
|
+
f"| {s.get('pin', 'N/A')} "
|
|
103
|
+
f"| {len(wa)} |"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
quality_breakdown_rows = []
|
|
107
|
+
for s in completed:
|
|
108
|
+
qs = s.get("quality_score")
|
|
109
|
+
quality_breakdown_rows.append(f"- **{s['name']}**: {qs if qs is not None else 'N/A'}")
|
|
110
|
+
|
|
111
|
+
if not quality_breakdown_rows:
|
|
112
|
+
quality_breakdown_rows.append("No completed skills with quality scores.")
|
|
113
|
+
|
|
114
|
+
if all_workarounds:
|
|
115
|
+
workarounds_list_items = [f"- `{fp}`" for fp in all_workarounds]
|
|
116
|
+
else:
|
|
117
|
+
workarounds_list_items = ["No workarounds applied."]
|
|
118
|
+
|
|
119
|
+
duration_table_rows = []
|
|
120
|
+
for s in skills:
|
|
121
|
+
s_start = _parse_iso(s.get("started_at"))
|
|
122
|
+
s_end = _parse_iso(s.get("completed_at"))
|
|
123
|
+
s_start_str = s.get("started_at", "N/A") or "N/A"
|
|
124
|
+
s_end_str = s.get("completed_at", "N/A") or "N/A"
|
|
125
|
+
dur = _format_duration(s_start, s_end)
|
|
126
|
+
duration_table_rows.append(f"| {s.get('name', '')} | {s_start_str} | {s_end_str} | {dur} |")
|
|
127
|
+
|
|
128
|
+
failed_skipped_lines = []
|
|
129
|
+
if failed:
|
|
130
|
+
failed_skipped_lines.append("### Failed Skills\n")
|
|
131
|
+
for s in failed:
|
|
132
|
+
failed_skipped_lines.append(f"- **{s['name']}** (Tier {s.get('tier', '?')})")
|
|
133
|
+
if skipped:
|
|
134
|
+
failed_skipped_lines.append("\n### Skipped Skills\n")
|
|
135
|
+
for s in skipped:
|
|
136
|
+
failed_skipped_lines.append(f"- **{s['name']}** (Tier {s.get('tier', '?')})")
|
|
137
|
+
if not failed and not skipped:
|
|
138
|
+
if skills:
|
|
139
|
+
failed_skipped_lines.append("All skills completed successfully.")
|
|
140
|
+
else:
|
|
141
|
+
failed_skipped_lines.append("No skills in campaign.")
|
|
142
|
+
|
|
143
|
+
quality_gate = campaign.get("quality_gate", {})
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"campaign_name": campaign.get("name", ""),
|
|
147
|
+
"started_at": started_at_str or "N/A",
|
|
148
|
+
"completed_at": last_updated_str or "N/A",
|
|
149
|
+
"duration": _format_duration(started_at, last_updated),
|
|
150
|
+
"quality_gate_hard": quality_gate.get("hard", "N/A"),
|
|
151
|
+
"quality_gate_soft_target": str(quality_gate.get("soft_target", "N/A")),
|
|
152
|
+
"quality_gate_soft_fallback": str(quality_gate.get("soft_fallback", "N/A")),
|
|
153
|
+
"skills_completed": str(len(completed)),
|
|
154
|
+
"skills_failed": str(len(failed)),
|
|
155
|
+
"skills_skipped": str(len(skipped)),
|
|
156
|
+
"skills_table": "\n".join(skills_table_rows),
|
|
157
|
+
"quality_min": str(quality_min),
|
|
158
|
+
"quality_max": str(quality_max),
|
|
159
|
+
"quality_avg": str(quality_avg),
|
|
160
|
+
"quality_breakdown": "\n".join(quality_breakdown_rows),
|
|
161
|
+
"total_workarounds": str(len(all_workarounds)),
|
|
162
|
+
"skills_with_workarounds": str(skills_with_wa),
|
|
163
|
+
"workarounds_list": "\n".join(workarounds_list_items),
|
|
164
|
+
"duration_table": "\n".join(duration_table_rows),
|
|
165
|
+
"failed_skipped_section": "\n".join(failed_skipped_lines),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run(state_file: str, template_file: str, output_file: str) -> int:
|
|
170
|
+
state_path = Path(state_file)
|
|
171
|
+
template_path = Path(template_file)
|
|
172
|
+
output_path = Path(output_file)
|
|
173
|
+
|
|
174
|
+
if not state_path.is_file():
|
|
175
|
+
_emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
|
|
176
|
+
return 2
|
|
177
|
+
|
|
178
|
+
if not template_path.is_file():
|
|
179
|
+
_emit_error(f"Template file not found: {template_file}", "TEMPLATE_NOT_FOUND")
|
|
180
|
+
return 2
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
state = _load_yaml(state_path)
|
|
184
|
+
except Exception as exc:
|
|
185
|
+
_emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
|
|
186
|
+
return 2
|
|
187
|
+
|
|
188
|
+
if not isinstance(state, dict):
|
|
189
|
+
_emit_error("State file root is not a mapping", "INVALID_STATE")
|
|
190
|
+
return 2
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
template = template_path.read_text(encoding="utf-8")
|
|
194
|
+
except Exception as exc:
|
|
195
|
+
_emit_error(f"Failed to read template file: {exc}", "TEMPLATE_READ_ERROR")
|
|
196
|
+
return 2
|
|
197
|
+
|
|
198
|
+
try:
|
|
199
|
+
aggregates = _compute_aggregates(state)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
_emit_error(f"Failed to compute report aggregates: {exc}", "AGGREGATE_ERROR")
|
|
202
|
+
return 2
|
|
203
|
+
|
|
204
|
+
report = template
|
|
205
|
+
for key, value in aggregates.items():
|
|
206
|
+
report = report.replace("{{" + key + "}}", value)
|
|
207
|
+
|
|
208
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
try:
|
|
210
|
+
output_path.write_text(report, encoding="utf-8")
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
_emit_error(f"Failed to write report: {exc}", "WRITE_ERROR")
|
|
213
|
+
return 2
|
|
214
|
+
|
|
215
|
+
skills = state.get("skills", [])
|
|
216
|
+
completed_count = sum(1 for s in skills if s.get("status") == "completed")
|
|
217
|
+
failed_count = sum(1 for s in skills if s.get("status") == "failed")
|
|
218
|
+
quality_scores = {
|
|
219
|
+
s["name"]: s["quality_score"]
|
|
220
|
+
for s in skills
|
|
221
|
+
if s.get("status") == "completed" and s.get("quality_score") is not None
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
result = {
|
|
225
|
+
"status": "success",
|
|
226
|
+
"report_path": output_path.as_posix(),
|
|
227
|
+
"skills_completed": completed_count,
|
|
228
|
+
"skills_failed": failed_count,
|
|
229
|
+
"quality_scores": quality_scores,
|
|
230
|
+
"duration": aggregates["duration"],
|
|
231
|
+
}
|
|
232
|
+
json.dump(result, sys.stdout, separators=(",", ":"))
|
|
233
|
+
sys.stdout.write("\n")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main() -> int:
|
|
238
|
+
parser = argparse.ArgumentParser(
|
|
239
|
+
description="Generate a campaign report from state and template.",
|
|
240
|
+
)
|
|
241
|
+
parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml")
|
|
242
|
+
parser.add_argument("--template-file", required=True, help="Path to campaign-report-template.md")
|
|
243
|
+
parser.add_argument("--output-file", required=True, help="Path to write the generated report")
|
|
244
|
+
args = parser.parse_args()
|
|
245
|
+
return run(args.state_file, args.template_file, args.output_file)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__":
|
|
249
|
+
raise SystemExit(main())
|