bmad-module-skill-forge 1.9.0 → 2.0.0
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/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/module-help.csv +2 -1
- 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-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +8 -3
- package/src/shared/scripts/skf-detect-language.py +5 -3
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- package/src/shared/scripts/skf-language-corpora.py +100 -0
- 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-shape-detect.py +576 -25
- package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
- package/src/shared/scripts/skf-validate-pins.py +2 -2
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +1 -1
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/step-auto-scope.md +196 -44
- package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
- package/src/skf-audit-skill/references/init.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/references/analyze-target.md +7 -4
- package/src/skf-brief-skill/references/confirm-brief.md +0 -1
- package/src/skf-brief-skill/references/gather-intent.md +8 -2
- package/src/skf-brief-skill/references/scope-definition.md +2 -1
- package/src/skf-brief-skill/references/step-auto-brief.md +21 -4
- package/src/skf-brief-skill/references/step-auto-validate.md +2 -2
- package/src/skf-brief-skill/references/write-brief.md +2 -3
- package/src/skf-campaign/SKILL.md +190 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +73 -0
- package/src/skf-campaign/manifest.yaml +11 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +122 -0
- package/src/skf-campaign/references/step-02-strategy.md +97 -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 +87 -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 +114 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +235 -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-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -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 +1 -1
- package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
- package/src/skf-create-skill/references/compile.md +4 -1
- package/src/skf-create-skill/references/extract.md +9 -1
- package/src/skf-create-skill/references/extraction-patterns.md +3 -1
- package/src/skf-create-skill/references/generate-artifacts.md +11 -2
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/report.md +17 -1
- package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
- package/src/skf-create-skill/references/step-doc-rot.md +4 -4
- package/src/skf-create-skill/references/step-doc-sources.md +11 -2
- package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
- package/src/skf-create-skill/references/validate.md +12 -3
- package/src/skf-drop-skill/SKILL.md +2 -2
- package/src/skf-drop-skill/references/execute.md +20 -9
- package/src/skf-drop-skill/references/report.md +2 -0
- package/src/skf-drop-skill/references/select.md +7 -2
- package/src/skf-forger/SKILL.md +12 -7
- package/src/skf-refine-architecture/SKILL.md +2 -1
- package/src/skf-refine-architecture/references/compile.md +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-rename-skill/references/execute.md +5 -4
- package/src/skf-rename-skill/references/rebuild-context.md +2 -2
- package/src/skf-rename-skill/references/select.md +3 -3
- package/src/skf-setup/SKILL.md +1 -1
- package/src/skf-setup/references/auto-index.md +3 -1
- package/src/skf-setup/references/detect-and-tier.md +4 -0
- package/src/skf-setup/references/report.md +4 -1
- package/src/skf-setup/references/tier-rules.md +1 -1
- package/src/skf-test-skill/references/coverage-check.md +10 -0
- package/src/skf-test-skill/references/init.md +1 -1
- package/src/skf-test-skill/references/source-access-protocol.md +13 -3
- package/src/skf-test-skill/scripts/compute-score.py +4 -3
- package/src/skf-update-skill/customize.toml +0 -9
- package/src/skf-update-skill/references/detect-changes.md +33 -3
- package/src/skf-update-skill/references/health-check.md +2 -2
- package/src/skf-update-skill/references/init.md +1 -0
- package/src/skf-update-skill/references/re-extract.md +15 -1
- package/src/skf-verify-stack/references/report.md +1 -1
- package/tools/cli/lib/ui.js +3 -2
- package/docs/deepwiki.md +0 -89
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.9"
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Campaign Provenance — verify repo access and record commit SHAs for all targets.
|
|
6
|
+
|
|
7
|
+
Replaces the step-04 prose that asked the LLM to string-munge each repo_url
|
|
8
|
+
("handle trailing .git or slashes"), run `gh repo view` + `gh api commits/{ref}`
|
|
9
|
+
per target, and aggregate failures across 15+ targets in-context. All of that
|
|
10
|
+
is deterministic; doing it by hand is both token-expensive and a fragile-parse
|
|
11
|
+
risk. This script owns the parse, the gh calls, and the aggregation, and — when
|
|
12
|
+
every (or nearly every) target fails the same way — collapses the wall of
|
|
13
|
+
near-identical errors into a single actionable root-cause hint instead of N
|
|
14
|
+
independent failures.
|
|
15
|
+
|
|
16
|
+
For each skill it resolves `{owner}/{repo}` from the brief's repo_url, picks the
|
|
17
|
+
ref (the skill's pin, or the repo default branch), verifies access, and records
|
|
18
|
+
the commit SHA.
|
|
19
|
+
|
|
20
|
+
CLI:
|
|
21
|
+
uv run campaign-provenance.py --state-file <path> --brief-file <path>
|
|
22
|
+
|
|
23
|
+
Output (JSON on stdout):
|
|
24
|
+
{
|
|
25
|
+
"results": [
|
|
26
|
+
{"name": "...", "repo_url": "...", "owner": "...", "repo": "...",
|
|
27
|
+
"ref": "...", "commit_sha": "..." | null,
|
|
28
|
+
"status": "accessible" | "inaccessible", "error": "..." | null}
|
|
29
|
+
],
|
|
30
|
+
"all_accessible": bool,
|
|
31
|
+
"inaccessible_count": N,
|
|
32
|
+
"systemic_hint": "..." | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
Exit codes:
|
|
36
|
+
0 all targets accessible
|
|
37
|
+
1 one or more targets inaccessible
|
|
38
|
+
2 error (missing files, bad YAML, gh not installed)
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import argparse
|
|
44
|
+
import json
|
|
45
|
+
import re
|
|
46
|
+
import shutil
|
|
47
|
+
import subprocess # noqa: S404 — invoking the user's authenticated `gh` CLI is the point
|
|
48
|
+
import sys
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
51
|
+
|
|
52
|
+
import yaml
|
|
53
|
+
|
|
54
|
+
# A command runner returns (returncode, stdout, stderr). Injectable for tests.
|
|
55
|
+
Runner = Callable[[List[str]], Tuple[int, str, str]]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _emit_error(message: str, code: str) -> None:
|
|
59
|
+
json.dump({"error": message, "code": code}, sys.stderr)
|
|
60
|
+
sys.stderr.write("\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_yaml(path: Path) -> Any:
|
|
64
|
+
with open(path, encoding="utf-8") as f:
|
|
65
|
+
return yaml.safe_load(f)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_owner_repo(repo_url: str) -> Optional[Tuple[str, str]]:
|
|
69
|
+
"""Extract (owner, repo) from a GitHub URL or `owner/repo` shorthand.
|
|
70
|
+
|
|
71
|
+
Tolerates trailing `.git`, trailing slashes, `git@` SSH form, and a bare
|
|
72
|
+
`owner/repo`. Returns None when no owner/repo pair can be recovered.
|
|
73
|
+
"""
|
|
74
|
+
if not repo_url or not isinstance(repo_url, str):
|
|
75
|
+
return None
|
|
76
|
+
url = repo_url.strip().rstrip("/")
|
|
77
|
+
if url.endswith(".git"):
|
|
78
|
+
url = url[:-4]
|
|
79
|
+
# git@github.com:owner/repo
|
|
80
|
+
ssh = re.match(r"^git@[^:]+:(?P<owner>[^/]+)/(?P<repo>[^/]+)$", url)
|
|
81
|
+
if ssh:
|
|
82
|
+
return ssh.group("owner"), ssh.group("repo")
|
|
83
|
+
# https://host/owner/repo (take the last two path segments)
|
|
84
|
+
https = re.match(r"^[a-zA-Z]+://[^/]+/(?P<rest>.+)$", url)
|
|
85
|
+
rest = https.group("rest") if https else url
|
|
86
|
+
parts = [p for p in rest.split("/") if p]
|
|
87
|
+
if len(parts) >= 2:
|
|
88
|
+
return parts[-2], parts[-1]
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _default_runner(args: List[str]) -> Tuple[int, str, str]:
|
|
93
|
+
proc = subprocess.run(args, capture_output=True, text=True) # noqa: S603
|
|
94
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _classify_error(stderr: str) -> str:
|
|
98
|
+
"""Bucket a gh failure so systemic root causes can be detected."""
|
|
99
|
+
low = stderr.lower()
|
|
100
|
+
if "authentication" in low or "gh auth" in low or "not logged" in low or "401" in low:
|
|
101
|
+
return "auth"
|
|
102
|
+
if "could not resolve" in low or "network" in low or "timeout" in low or "dial tcp" in low:
|
|
103
|
+
return "network"
|
|
104
|
+
if "rate limit" in low or "403" in low:
|
|
105
|
+
return "rate-limit"
|
|
106
|
+
if "not found" in low or "404" in low:
|
|
107
|
+
return "not-found"
|
|
108
|
+
return "other"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
_SYSTEMIC_HINTS = {
|
|
112
|
+
"auth": "All targets failed authentication — run `gh auth status` / `gh auth login`, then `campaign resume`.",
|
|
113
|
+
"network": "All targets failed with network errors — check connectivity, then `campaign resume`.",
|
|
114
|
+
"rate-limit": "All targets hit GitHub rate limiting — wait for the limit to reset, then `campaign resume`.",
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def run(state_file: str, brief_file: str, runner: Runner = _default_runner) -> int:
|
|
119
|
+
state_path = Path(state_file)
|
|
120
|
+
brief_path = Path(brief_file)
|
|
121
|
+
|
|
122
|
+
if not state_path.is_file():
|
|
123
|
+
_emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
|
|
124
|
+
return 2
|
|
125
|
+
if not brief_path.is_file():
|
|
126
|
+
_emit_error(f"Brief file not found: {brief_file}", "BRIEF_NOT_FOUND")
|
|
127
|
+
return 2
|
|
128
|
+
if shutil.which("gh") is None and runner is _default_runner:
|
|
129
|
+
_emit_error("GitHub CLI `gh` not found on PATH", "GH_NOT_FOUND")
|
|
130
|
+
return 2
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
state = _load_yaml(state_path)
|
|
134
|
+
except Exception as exc: # noqa: BLE001
|
|
135
|
+
_emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
|
|
136
|
+
return 2
|
|
137
|
+
try:
|
|
138
|
+
brief = _load_yaml(brief_path)
|
|
139
|
+
except Exception as exc: # noqa: BLE001
|
|
140
|
+
_emit_error(f"Failed to parse brief file: {exc}", "BRIEF_PARSE_ERROR")
|
|
141
|
+
return 2
|
|
142
|
+
|
|
143
|
+
skills = state.get("skills", [])
|
|
144
|
+
if not isinstance(skills, list):
|
|
145
|
+
_emit_error("State file 'skills' is not an array", "INVALID_STATE")
|
|
146
|
+
return 2
|
|
147
|
+
targets = brief.get("targets", [])
|
|
148
|
+
if not isinstance(targets, list):
|
|
149
|
+
_emit_error("Brief file 'targets' is not an array", "INVALID_BRIEF")
|
|
150
|
+
return 2
|
|
151
|
+
|
|
152
|
+
name_to_repo: Dict[str, str] = {t["name"]: t["repo_url"] for t in targets}
|
|
153
|
+
|
|
154
|
+
results: List[Dict[str, Any]] = []
|
|
155
|
+
error_classes: List[str] = []
|
|
156
|
+
|
|
157
|
+
for skill in skills:
|
|
158
|
+
name = skill["name"]
|
|
159
|
+
repo_url = name_to_repo.get(name)
|
|
160
|
+
record: Dict[str, Any] = {
|
|
161
|
+
"name": name,
|
|
162
|
+
"repo_url": repo_url,
|
|
163
|
+
"owner": None,
|
|
164
|
+
"repo": None,
|
|
165
|
+
"ref": None,
|
|
166
|
+
"commit_sha": None,
|
|
167
|
+
"status": "inaccessible",
|
|
168
|
+
"error": None,
|
|
169
|
+
}
|
|
170
|
+
if repo_url is None:
|
|
171
|
+
record["error"] = f"Skill '{name}' has no repo_url in brief targets"
|
|
172
|
+
error_classes.append("other")
|
|
173
|
+
results.append(record)
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
parsed = parse_owner_repo(repo_url)
|
|
177
|
+
if parsed is None:
|
|
178
|
+
record["error"] = f"Could not parse owner/repo from '{repo_url}'"
|
|
179
|
+
error_classes.append("other")
|
|
180
|
+
results.append(record)
|
|
181
|
+
continue
|
|
182
|
+
owner, repo = parsed
|
|
183
|
+
record["owner"], record["repo"] = owner, repo
|
|
184
|
+
|
|
185
|
+
rc, _out, err = runner(["gh", "repo", "view", f"{owner}/{repo}", "--json", "name"])
|
|
186
|
+
if rc != 0:
|
|
187
|
+
record["error"] = err.strip() or "gh repo view failed"
|
|
188
|
+
error_classes.append(_classify_error(err))
|
|
189
|
+
results.append(record)
|
|
190
|
+
continue
|
|
191
|
+
|
|
192
|
+
ref = skill.get("pin")
|
|
193
|
+
if not ref:
|
|
194
|
+
rc, out, err = runner(
|
|
195
|
+
["gh", "repo", "view", f"{owner}/{repo}", "--json", "defaultBranchRef", "--jq", ".defaultBranchRef.name"]
|
|
196
|
+
)
|
|
197
|
+
if rc != 0:
|
|
198
|
+
record["error"] = err.strip() or "could not resolve default branch"
|
|
199
|
+
error_classes.append(_classify_error(err))
|
|
200
|
+
results.append(record)
|
|
201
|
+
continue
|
|
202
|
+
ref = out.strip()
|
|
203
|
+
record["ref"] = ref
|
|
204
|
+
|
|
205
|
+
rc, out, err = runner(["gh", "api", f"repos/{owner}/{repo}/commits/{ref}", "--jq", ".sha"])
|
|
206
|
+
if rc != 0:
|
|
207
|
+
record["error"] = err.strip() or f"could not resolve commit for ref '{ref}'"
|
|
208
|
+
error_classes.append(_classify_error(err))
|
|
209
|
+
results.append(record)
|
|
210
|
+
continue
|
|
211
|
+
|
|
212
|
+
record["commit_sha"] = out.strip()
|
|
213
|
+
record["status"] = "accessible"
|
|
214
|
+
results.append(record)
|
|
215
|
+
|
|
216
|
+
inaccessible = [r for r in results if r["status"] != "accessible"]
|
|
217
|
+
systemic_hint: Optional[str] = None
|
|
218
|
+
if inaccessible and len(inaccessible) == len(results):
|
|
219
|
+
# Every target failed — if they share a class, surface one root cause.
|
|
220
|
+
distinct = set(error_classes)
|
|
221
|
+
if len(distinct) == 1:
|
|
222
|
+
systemic_hint = _SYSTEMIC_HINTS.get(next(iter(distinct)))
|
|
223
|
+
|
|
224
|
+
output = {
|
|
225
|
+
"results": results,
|
|
226
|
+
"all_accessible": not inaccessible,
|
|
227
|
+
"inaccessible_count": len(inaccessible),
|
|
228
|
+
"systemic_hint": systemic_hint,
|
|
229
|
+
}
|
|
230
|
+
json.dump(output, sys.stdout, separators=(",", ":"))
|
|
231
|
+
sys.stdout.write("\n")
|
|
232
|
+
return 0 if not inaccessible else 1
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def main(argv: list[str] | None = None) -> int:
|
|
236
|
+
parser = argparse.ArgumentParser(
|
|
237
|
+
prog="campaign-provenance",
|
|
238
|
+
description="Verify repo access and record commit SHAs for all campaign targets.",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml")
|
|
241
|
+
parser.add_argument("--brief-file", required=True, help="Path to campaign-brief.yaml")
|
|
242
|
+
args = parser.parse_args(argv)
|
|
243
|
+
return run(args.state_file, args.brief_file)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
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())
|