bmad-module-skill-forge 1.8.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.
Files changed (136) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +10 -5
  3. package/docs/_data/pinned.yaml +2 -2
  4. package/docs/agents.md +11 -2
  5. package/docs/architecture.md +5 -4
  6. package/docs/bmad-synergy.md +42 -3
  7. package/docs/campaign.md +172 -0
  8. package/docs/examples.md +7 -3
  9. package/docs/forge-auto.md +90 -0
  10. package/docs/getting-started.md +9 -0
  11. package/docs/how-it-works.md +6 -4
  12. package/docs/index.md +4 -3
  13. package/docs/skill-model.md +1 -1
  14. package/docs/troubleshooting.md +17 -1
  15. package/docs/verifying-a-skill.md +9 -3
  16. package/docs/why-skf.md +1 -1
  17. package/docs/workflows.md +73 -27
  18. package/package.json +2 -2
  19. package/src/module-help.csv +2 -1
  20. package/src/shared/_known-workarounds.yaml +155 -0
  21. package/src/shared/data/language-corpora.json +32 -0
  22. package/src/shared/references/pipeline-contracts.md +14 -2
  23. package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
  24. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  25. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  26. package/src/shared/scripts/skf-detect-docs.py +417 -0
  27. package/src/shared/scripts/skf-detect-language.py +5 -3
  28. package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
  29. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  30. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  31. package/src/shared/scripts/skf-language-corpora.py +100 -0
  32. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  33. package/src/shared/scripts/skf-preapply.py +192 -0
  34. package/src/shared/scripts/skf-shape-detect.py +1114 -0
  35. package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
  36. package/src/shared/scripts/skf-validate-pins.py +368 -0
  37. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  38. package/src/skf-analyze-source/SKILL.md +26 -20
  39. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  40. package/src/skf-analyze-source/references/init.md +30 -0
  41. package/src/skf-analyze-source/references/step-auto-scope.md +677 -0
  42. package/src/skf-analyze-source/references/step-shape-detect.md +86 -0
  43. package/src/skf-audit-skill/SKILL.md +1 -0
  44. package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
  45. package/src/skf-audit-skill/references/init.md +1 -1
  46. package/src/skf-audit-skill/references/report.md +4 -0
  47. package/src/skf-audit-skill/references/severity-classify.md +1 -1
  48. package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
  49. package/src/skf-brief-skill/SKILL.md +7 -3
  50. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  51. package/src/skf-brief-skill/references/analyze-target.md +7 -4
  52. package/src/skf-brief-skill/references/confirm-brief.md +0 -1
  53. package/src/skf-brief-skill/references/gather-intent.md +22 -2
  54. package/src/skf-brief-skill/references/scope-definition.md +2 -1
  55. package/src/skf-brief-skill/references/step-auto-brief.md +188 -0
  56. package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
  57. package/src/skf-brief-skill/references/write-brief.md +2 -3
  58. package/src/skf-campaign/SKILL.md +190 -0
  59. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  60. package/src/skf-campaign/customize.toml +73 -0
  61. package/src/skf-campaign/manifest.yaml +11 -0
  62. package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
  63. package/src/skf-campaign/references/health-check.md +35 -0
  64. package/src/skf-campaign/references/step-01-setup.md +122 -0
  65. package/src/skf-campaign/references/step-02-strategy.md +97 -0
  66. package/src/skf-campaign/references/step-03-pins.md +56 -0
  67. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  68. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  69. package/src/skf-campaign/references/step-06-batch.md +87 -0
  70. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  71. package/src/skf-campaign/references/step-08-verify.md +75 -0
  72. package/src/skf-campaign/references/step-09-refine.md +83 -0
  73. package/src/skf-campaign/references/step-10-export.md +106 -0
  74. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  75. package/src/skf-campaign/references/step-resume.md +114 -0
  76. package/src/skf-campaign/scripts/.gitkeep +0 -0
  77. package/src/skf-campaign/scripts/campaign-deps.py +235 -0
  78. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  79. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  80. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  81. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  82. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  83. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  84. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  85. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  86. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  87. package/src/skf-create-skill/SKILL.md +4 -1
  88. package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
  89. package/src/skf-create-skill/assets/skill-sections.md +2 -0
  90. package/src/skf-create-skill/references/compile.md +5 -2
  91. package/src/skf-create-skill/references/extract.md +9 -1
  92. package/src/skf-create-skill/references/extraction-patterns.md +3 -1
  93. package/src/skf-create-skill/references/generate-artifacts.md +11 -2
  94. package/src/skf-create-skill/references/health-check.md +2 -2
  95. package/src/skf-create-skill/references/report.md +17 -1
  96. package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
  97. package/src/skf-create-skill/references/step-auto-shard.md +110 -0
  98. package/src/skf-create-skill/references/step-doc-rot.md +109 -0
  99. package/src/skf-create-skill/references/step-doc-sources.md +123 -0
  100. package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
  101. package/src/skf-create-skill/references/validate.md +12 -3
  102. package/src/skf-drop-skill/SKILL.md +2 -2
  103. package/src/skf-drop-skill/references/execute.md +20 -9
  104. package/src/skf-drop-skill/references/report.md +2 -0
  105. package/src/skf-drop-skill/references/select.md +7 -2
  106. package/src/skf-forger/SKILL.md +15 -4
  107. package/src/skf-refine-architecture/SKILL.md +2 -1
  108. package/src/skf-refine-architecture/references/compile.md +1 -1
  109. package/src/skf-refine-architecture/references/report.md +1 -1
  110. package/src/skf-rename-skill/SKILL.md +2 -2
  111. package/src/skf-rename-skill/references/execute.md +5 -4
  112. package/src/skf-rename-skill/references/rebuild-context.md +2 -2
  113. package/src/skf-rename-skill/references/select.md +3 -3
  114. package/src/skf-setup/SKILL.md +1 -1
  115. package/src/skf-setup/references/auto-index.md +3 -1
  116. package/src/skf-setup/references/detect-and-tier.md +4 -0
  117. package/src/skf-setup/references/report.md +4 -1
  118. package/src/skf-setup/references/tier-rules.md +1 -1
  119. package/src/skf-test-skill/SKILL.md +8 -7
  120. package/src/skf-test-skill/customize.toml +2 -1
  121. package/src/skf-test-skill/references/coverage-check.md +10 -0
  122. package/src/skf-test-skill/references/external-validators.md +1 -1
  123. package/src/skf-test-skill/references/init.md +16 -1
  124. package/src/skf-test-skill/references/report.md +5 -1
  125. package/src/skf-test-skill/references/score.md +95 -6
  126. package/src/skf-test-skill/references/source-access-protocol.md +13 -3
  127. package/src/skf-test-skill/references/step-hard-gate.md +73 -0
  128. package/src/skf-test-skill/scripts/compute-score.py +4 -3
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/customize.toml +0 -9
  131. package/src/skf-update-skill/references/detect-changes.md +33 -3
  132. package/src/skf-update-skill/references/health-check.md +2 -2
  133. package/src/skf-update-skill/references/init.md +1 -0
  134. package/src/skf-update-skill/references/re-extract.md +15 -1
  135. package/src/skf-verify-stack/references/report.md +1 -1
  136. package/tools/cli/lib/ui.js +3 -2
@@ -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())