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.
Files changed (118) 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 +30 -7
  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 +2 -2
  16. package/docs/why-skf.md +1 -1
  17. package/docs/workflows.md +64 -24
  18. package/package.json +2 -2
  19. package/src/module-help.csv +2 -1
  20. package/src/shared/data/language-corpora.json +32 -0
  21. package/src/shared/references/pipeline-contracts.md +6 -3
  22. package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
  23. package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
  24. package/src/shared/scripts/skf-detect-docs.py +8 -3
  25. package/src/shared/scripts/skf-detect-language.py +5 -3
  26. package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
  27. package/src/shared/scripts/skf-extract-public-api.py +53 -0
  28. package/src/shared/scripts/skf-language-corpora.py +100 -0
  29. package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
  30. package/src/shared/scripts/skf-preapply.py +1 -1
  31. package/src/shared/scripts/skf-shape-detect.py +576 -25
  32. package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
  33. package/src/shared/scripts/skf-validate-pins.py +2 -2
  34. package/src/shared/scripts/skf-write-skill-brief.py +21 -5
  35. package/src/skf-analyze-source/SKILL.md +1 -1
  36. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  37. package/src/skf-analyze-source/references/step-auto-scope.md +196 -44
  38. package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
  39. package/src/skf-audit-skill/references/init.md +1 -1
  40. package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
  41. package/src/skf-brief-skill/references/analyze-target.md +7 -4
  42. package/src/skf-brief-skill/references/confirm-brief.md +0 -1
  43. package/src/skf-brief-skill/references/gather-intent.md +8 -2
  44. package/src/skf-brief-skill/references/scope-definition.md +2 -1
  45. package/src/skf-brief-skill/references/step-auto-brief.md +21 -4
  46. package/src/skf-brief-skill/references/step-auto-validate.md +2 -2
  47. package/src/skf-brief-skill/references/write-brief.md +2 -3
  48. package/src/skf-campaign/SKILL.md +190 -0
  49. package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
  50. package/src/skf-campaign/customize.toml +73 -0
  51. package/src/skf-campaign/manifest.yaml +11 -0
  52. package/src/skf-campaign/references/campaign-directive-spec.md +121 -0
  53. package/src/skf-campaign/references/health-check.md +35 -0
  54. package/src/skf-campaign/references/step-01-setup.md +122 -0
  55. package/src/skf-campaign/references/step-02-strategy.md +97 -0
  56. package/src/skf-campaign/references/step-03-pins.md +56 -0
  57. package/src/skf-campaign/references/step-04-provenance.md +63 -0
  58. package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
  59. package/src/skf-campaign/references/step-06-batch.md +87 -0
  60. package/src/skf-campaign/references/step-07-capstone.md +63 -0
  61. package/src/skf-campaign/references/step-08-verify.md +75 -0
  62. package/src/skf-campaign/references/step-09-refine.md +83 -0
  63. package/src/skf-campaign/references/step-10-export.md +106 -0
  64. package/src/skf-campaign/references/step-11-maintenance.md +84 -0
  65. package/src/skf-campaign/references/step-resume.md +114 -0
  66. package/src/skf-campaign/scripts/.gitkeep +0 -0
  67. package/src/skf-campaign/scripts/campaign-deps.py +235 -0
  68. package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
  69. package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
  70. package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
  71. package/src/skf-campaign/scripts/campaign-report.py +249 -0
  72. package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
  73. package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
  74. package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
  75. package/src/skf-campaign/templates/campaign-report-template.md +54 -0
  76. package/src/skf-campaign/templates/kickoff-template.md +48 -0
  77. package/src/skf-create-skill/SKILL.md +1 -1
  78. package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
  79. package/src/skf-create-skill/references/compile.md +4 -1
  80. package/src/skf-create-skill/references/extract.md +9 -1
  81. package/src/skf-create-skill/references/extraction-patterns.md +3 -1
  82. package/src/skf-create-skill/references/generate-artifacts.md +11 -2
  83. package/src/skf-create-skill/references/health-check.md +2 -2
  84. package/src/skf-create-skill/references/report.md +17 -1
  85. package/src/skf-create-skill/references/source-resolution-protocols.md +1 -1
  86. package/src/skf-create-skill/references/step-doc-rot.md +4 -4
  87. package/src/skf-create-skill/references/step-doc-sources.md +11 -2
  88. package/src/skf-create-skill/references/sub/fetch-docs.md +29 -0
  89. package/src/skf-create-skill/references/validate.md +12 -3
  90. package/src/skf-drop-skill/SKILL.md +2 -2
  91. package/src/skf-drop-skill/references/execute.md +20 -9
  92. package/src/skf-drop-skill/references/report.md +2 -0
  93. package/src/skf-drop-skill/references/select.md +7 -2
  94. package/src/skf-forger/SKILL.md +12 -7
  95. package/src/skf-refine-architecture/SKILL.md +2 -1
  96. package/src/skf-refine-architecture/references/compile.md +1 -1
  97. package/src/skf-refine-architecture/references/report.md +1 -1
  98. package/src/skf-rename-skill/SKILL.md +2 -2
  99. package/src/skf-rename-skill/references/execute.md +5 -4
  100. package/src/skf-rename-skill/references/rebuild-context.md +2 -2
  101. package/src/skf-rename-skill/references/select.md +3 -3
  102. package/src/skf-setup/SKILL.md +1 -1
  103. package/src/skf-setup/references/auto-index.md +3 -1
  104. package/src/skf-setup/references/detect-and-tier.md +4 -0
  105. package/src/skf-setup/references/report.md +4 -1
  106. package/src/skf-setup/references/tier-rules.md +1 -1
  107. package/src/skf-test-skill/references/coverage-check.md +10 -0
  108. package/src/skf-test-skill/references/init.md +1 -1
  109. package/src/skf-test-skill/references/source-access-protocol.md +13 -3
  110. package/src/skf-test-skill/scripts/compute-score.py +4 -3
  111. package/src/skf-update-skill/customize.toml +0 -9
  112. package/src/skf-update-skill/references/detect-changes.md +33 -3
  113. package/src/skf-update-skill/references/health-check.md +2 -2
  114. package/src/skf-update-skill/references/init.md +1 -0
  115. package/src/skf-update-skill/references/re-extract.md +15 -1
  116. package/src/skf-verify-stack/references/report.md +1 -1
  117. package/tools/cli/lib/ui.js +3 -2
  118. package/docs/deepwiki.md +0 -89
@@ -0,0 +1,114 @@
1
+ ---
2
+ stateSchemaFile: 'assets/campaign-state-schema.json'
3
+ stateFile: '{campaignWorkspacePath}/_campaign-state.yaml'
4
+ backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak'
5
+ validateScript: 'scripts/campaign-validate-state.py'
6
+ ---
7
+
8
+ <!-- Config: communicate in {communication_language}. -->
9
+
10
+ # Resume
11
+
12
+ ## STEP GOAL:
13
+
14
+ Validate campaign state integrity, determine the resume point, and chain to the appropriate stage step file. This is a read-only routing step — it does not modify state.
15
+
16
+ ## RULES
17
+
18
+ - This step is **read-only by default** — it does NOT modify `{stateFile}` except in the one recovery case below (restoring a valid `.bak` over a corrupt primary), which is the State Contract's advertised safety net.
19
+ - Validate state on load via `uv run {validateScript} --state-file {stateFile}`; the recovery path in §1 governs what happens on failure.
20
+ - The chain target is determined dynamically from state — there is no fixed `nextStepFile`.
21
+ - If `{headless_mode}` is true, auto-proceed through any confirmation gates with the default action and log each auto-decision.
22
+
23
+ ## TASKS
24
+
25
+ ### §1 — Read + Validate State (with `.bak` recovery)
26
+
27
+ Load `{stateFile}`. If the file does not exist, HALT: "No campaign state found. Run `campaign` to start a new campaign."
28
+
29
+ Run `uv run {validateScript} --state-file {stateFile}`. If it succeeds (exit 0), proceed to §2.
30
+
31
+ If it fails (primary missing/corrupt YAML/schema-invalid), **attempt automatic recovery from the backup** rather than dead-halting — this is exactly the crash-during-write case the State Contract promises `.bak` covers:
32
+
33
+ 1. If `{backupFile}` exists, run `uv run {validateScript} --state-file {backupFile}`.
34
+ 2. If the backup is valid: copy `{backupFile}` over `{stateFile}`, log to the decision log "primary corrupt — recovered from backup as of {bak.last_updated}", inform the operator, and continue with the recovered state.
35
+ 3. If the backup is missing or also invalid: HALT with exit code 9 (`corrupt-state`), reporting both the primary and backup validation errors so the operator knows neither is usable.
36
+
37
+ ### §2 — Backup Consistency Check
38
+
39
+ Check if `{backupFile}` exists.
40
+
41
+ **If `.bak` does not exist:** warn "No backup file found — campaign may have been created but never modified." Continue.
42
+
43
+ **If `.bak` exists** (and §1 did not already recover from it):
44
+
45
+ 1. Run `uv run {validateScript} --state-file {backupFile}`. If invalid, warn: "Backup file fails validation — cannot use for recovery." Continue with the primary.
46
+ 2. Compare primary vs backup. If `primary.campaign.last_updated < backup.campaign.last_updated` OR `primary.campaign.current_stage < backup.campaign.current_stage`, the primary looks behind the backup (possible crash during last write). Present a recovery choice:
47
+ - `[R]ecover` — copy `{backupFile}` over `{stateFile}` and resume from the backup's state.
48
+ - `[K]eep` — keep the primary as authoritative (the default).
49
+
50
+ In headless mode, default to `[K]eep` and log the auto-decision (a behind-backup primary may be intentional; never silently overwrite without a clear corruption signal — that case is handled in §1). Otherwise the primary is authoritative.
51
+
52
+ ### §3 — Determine Resume Point
53
+
54
+ Two paths based on whether `--from=<skill>` was provided in the invocation:
55
+
56
+ **With `--from=<skill>`:**
57
+
58
+ 1. Find the named skill in `skills[]` by `name`.
59
+ 2. If not found → HALT: "Unknown skill '{name}'. Known skills: {comma-separated list of all skill names from state}."
60
+ 3. If the skill's `status` is `"completed"`, `"failed"`, or `"skipped"`, the operator may have meant to re-run it (e.g. it passed with a low score) rather than skip past it. Present a choice:
61
+ - `[R]e-run` — reset the named skill to `"pending"` and resume from its stage. (Read-only step caveat: this single status reset follows read-backup-modify-write — back up first.)
62
+ - `[N]ext` — find the next skill in `dependency_graph.execution_order` after the named one whose `status` is `"pending"` or `"active"` and resume there. If none found → HALT: "All remaining skills are complete. Run `campaign` to start a new campaign."
63
+ - `[H]alt` — stop without resuming.
64
+
65
+ In headless mode, default to `[N]ext` and log the auto-decision. Log the chosen action to the decision log.
66
+ 4. If an active skill already exists in `skills[]` AND it is a different skill from the `--from` target, warn: "Skill '{active_name}' is currently active — honoring explicit --from override."
67
+ 5. Determine the target step file:
68
+ - Tier A skill with status `"pending"` or `"active"` → stage 4 (`step-05-skill-loop.md`)
69
+ - Tier B skill with status `"pending"` or `"active"` → stage 5 (`step-06-batch.md`)
70
+
71
+ **Without `--from`:**
72
+
73
+ 1. Scan `skills[]` for any skill with `status == "active"`.
74
+ - If found and `tier == "A"` → resume target is stage 4 (`step-05-skill-loop.md`). The skill loop's §4 will skip completed skills until it reaches the active one.
75
+ - If found and `tier == "B"` → resume target is stage 5 (`step-06-batch.md`). The batch step processes Tier B skills.
76
+ 2. If no active skill → the next stage to run is `campaign.current_stage + 1`, because `current_stage` records the highest **completed** stage (each stage writes its own number only after its work and gates finish, so a mid-stage halt leaves the previous stage's number on disk). **Terminal cap:** if `campaign.current_stage` is `10`, the resolved stage is `10` itself (`step-11-maintenance.md`) — never `11`; the existing terminal-HALT check in §4 governs whether a stage-10 campaign is already done.
77
+ 3. Map the resolved stage number to the corresponding step file using the stage table in §4.
78
+
79
+ ### §4 — Resume Routing
80
+
81
+ Map the resolved stage number to its step file. Both §3 branches feed this table an **already-resolved** stage number: the active-skill branch supplies stage 4 or 5 directly (and BYPASSES the `+1`), while the no-active-skill branch supplies `current_stage + 1` (terminal-capped at 10). Do not apply the `+1` again here.
82
+
83
+ | Stage | Step File |
84
+ |-------|-----------|
85
+ | 0 | step-01-setup.md |
86
+ | 1 | step-02-strategy.md |
87
+ | 2 | step-03-pins.md |
88
+ | 3 | step-04-provenance.md |
89
+ | 4 | step-05-skill-loop.md |
90
+ | 5 | step-06-batch.md |
91
+ | 6 | step-07-capstone.md |
92
+ | 7 | step-08-verify.md |
93
+ | 8 | step-09-refine.md |
94
+ | 9 | step-10-export.md |
95
+ | 10 | step-11-maintenance.md |
96
+
97
+ Display a resume summary before chaining:
98
+
99
+ ```
100
+ CAMPAIGN RESUME: {campaign.name}
101
+
102
+ Resuming from: Stage {stage_number} — {stage_name}
103
+ Target skill: {skill_name} (if --from was used, otherwise "auto-detected" or "N/A")
104
+ Skills completed: {completed_count} / {total_count}
105
+ Skills remaining: {pending_count} pending, {active_count} active, {failed_count} failed, {skipped_count} skipped
106
+ Last updated: {campaign.last_updated}
107
+ ```
108
+
109
+ If `campaign.current_stage` is `10` and all skills have status `"completed"`, `"failed"`, or `"skipped"`:
110
+ HALT: "Campaign has reached its final stage. All skills have been processed."
111
+
112
+ ## OUTPUT
113
+
114
+ Chain to the determined step file.
File without changes
@@ -0,0 +1,235 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = ["pyyaml"]
4
+ # ///
5
+ """Campaign Deps — dependency computation and enforcement for campaign skills.
6
+
7
+ Two modes:
8
+ --compute: topological sort of all skills from depends_on edges
9
+ --check: verify a single skill's dependencies are satisfied
10
+
11
+ CLI:
12
+ uv run campaign-deps.py --compute --state-file <path>
13
+ uv run campaign-deps.py --check --state-file <path> --skill <name>
14
+ uv run campaign-deps.py --check --state-file <path> --skill <name> --force
15
+
16
+ Output (JSON on stdout):
17
+ --compute:
18
+ {"execution_order": [...], "circular_deps_detected": bool, "cycle_participants": [...] | null}
19
+
20
+ --check:
21
+ {"skill": "name", "ready": bool, "unmet_deps": [...], "forced": bool}
22
+
23
+ Exit codes:
24
+ 0 success / ready / force-override
25
+ 1 circular deps / unmet deps / dangling reference
26
+ 2 error (missing file, bad YAML)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import argparse
32
+ import json
33
+ import sys
34
+ import heapq
35
+ from pathlib import Path
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ import yaml
39
+
40
+
41
+ def _emit_error(message: str, code: str) -> None:
42
+ json.dump({"error": message, "code": code}, sys.stderr)
43
+ sys.stderr.write("\n")
44
+
45
+
46
+ def _load_yaml(path: Path) -> Any:
47
+ with open(path, encoding="utf-8") as f:
48
+ return yaml.safe_load(f)
49
+
50
+
51
+ def _build_skill_map(skills: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
52
+ return {s["name"]: s for s in skills}
53
+
54
+
55
+ def _validate_deps(
56
+ skill_map: Dict[str, Dict[str, Any]],
57
+ ) -> Optional[List[str]]:
58
+ dangling: List[str] = []
59
+ for name, skill in skill_map.items():
60
+ for dep in skill.get("depends_on", []) or []:
61
+ if dep not in skill_map:
62
+ dangling.append(f"{name} depends on unknown skill '{dep}'")
63
+ return dangling if dangling else None
64
+
65
+
66
+ def compute(state_file: str) -> int:
67
+ path = Path(state_file)
68
+ if not path.is_file():
69
+ _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
70
+ return 2
71
+
72
+ try:
73
+ state = _load_yaml(path)
74
+ except Exception as exc:
75
+ _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
76
+ return 2
77
+
78
+ skills = state.get("skills", [])
79
+ if not isinstance(skills, list):
80
+ _emit_error("State file 'skills' is not an array", "INVALID_STATE")
81
+ return 2
82
+
83
+ skill_map = _build_skill_map(skills)
84
+
85
+ dangling = _validate_deps(skill_map)
86
+ if dangling:
87
+ _emit_error(
88
+ f"Dangling dependency references: {'; '.join(dangling)}",
89
+ "DANGLING_DEPENDENCY",
90
+ )
91
+ return 1
92
+
93
+ in_degree: Dict[str, int] = {name: 0 for name in skill_map}
94
+ adjacency: Dict[str, List[str]] = {name: [] for name in skill_map}
95
+
96
+ for name, skill in skill_map.items():
97
+ for dep in skill.get("depends_on", []) or []:
98
+ adjacency[dep].append(name)
99
+ in_degree[name] += 1
100
+
101
+ def _tier_key(n: str) -> tuple[int, str]:
102
+ return (0 if skill_map[n].get("tier") == "A" else 1, n)
103
+
104
+ heap: List[tuple[int, str]] = []
105
+ for n in skill_map:
106
+ if in_degree[n] == 0:
107
+ heapq.heappush(heap, _tier_key(n))
108
+
109
+ execution_order: List[str] = []
110
+
111
+ while heap:
112
+ _, current = heapq.heappop(heap)
113
+ execution_order.append(current)
114
+
115
+ for dependent in adjacency[current]:
116
+ in_degree[dependent] -= 1
117
+ if in_degree[dependent] == 0:
118
+ heapq.heappush(heap, _tier_key(dependent))
119
+
120
+ if len(execution_order) < len(skill_map):
121
+ cycle_participants = sorted(
122
+ n for n in skill_map if n not in set(execution_order)
123
+ )
124
+ output = {
125
+ "execution_order": execution_order,
126
+ "circular_deps_detected": True,
127
+ "cycle_participants": cycle_participants,
128
+ }
129
+ json.dump(output, sys.stdout, separators=(",", ":"))
130
+ sys.stdout.write("\n")
131
+ return 1
132
+
133
+ output = {
134
+ "execution_order": execution_order,
135
+ "circular_deps_detected": False,
136
+ "cycle_participants": None,
137
+ }
138
+ json.dump(output, sys.stdout, separators=(",", ":"))
139
+ sys.stdout.write("\n")
140
+ return 0
141
+
142
+
143
+ def check(state_file: str, skill_name: str, force: bool = False) -> int:
144
+ path = Path(state_file)
145
+ if not path.is_file():
146
+ _emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
147
+ return 2
148
+
149
+ try:
150
+ state = _load_yaml(path)
151
+ except Exception as exc:
152
+ _emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
153
+ return 2
154
+
155
+ skills = state.get("skills", [])
156
+ if not isinstance(skills, list):
157
+ _emit_error("State file 'skills' is not an array", "INVALID_STATE")
158
+ return 2
159
+
160
+ skill_map = _build_skill_map(skills)
161
+
162
+ if skill_name not in skill_map:
163
+ _emit_error(f"Skill '{skill_name}' not found in state", "SKILL_NOT_FOUND")
164
+ return 2
165
+
166
+ deps = skill_map[skill_name].get("depends_on", []) or []
167
+ unmet: List[str] = []
168
+ for dep in deps:
169
+ if dep not in skill_map:
170
+ _emit_error(
171
+ f"Skill '{skill_name}' depends on unknown skill '{dep}'",
172
+ "DANGLING_DEPENDENCY",
173
+ )
174
+ return 1
175
+ if skill_map[dep].get("status") != "completed":
176
+ unmet.append(dep)
177
+
178
+ ready = len(unmet) == 0
179
+ forced = force and not ready
180
+
181
+ if forced:
182
+ json.dump(
183
+ {"warning": f"Forcing past unmet dependencies for '{skill_name}'", "unmet": unmet},
184
+ sys.stderr,
185
+ )
186
+ sys.stderr.write("\n")
187
+
188
+ output = {
189
+ "skill": skill_name,
190
+ "ready": ready,
191
+ "unmet_deps": unmet,
192
+ "forced": forced,
193
+ }
194
+ json.dump(output, sys.stdout, separators=(",", ":"))
195
+ sys.stdout.write("\n")
196
+
197
+ if not ready and not force:
198
+ return 1
199
+ return 0
200
+
201
+
202
+ def main() -> int:
203
+ parser = argparse.ArgumentParser(
204
+ description="Campaign dependency computation and enforcement.",
205
+ )
206
+ mode = parser.add_mutually_exclusive_group(required=True)
207
+ mode.add_argument(
208
+ "--compute",
209
+ action="store_true",
210
+ help="Compute execution order via topological sort",
211
+ )
212
+ mode.add_argument(
213
+ "--check",
214
+ action="store_true",
215
+ help="Check if a skill's dependencies are satisfied",
216
+ )
217
+ parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml")
218
+ parser.add_argument("--skill", help="Skill name (required for --check)")
219
+ parser.add_argument(
220
+ "--force",
221
+ action="store_true",
222
+ help="Force past dependency check (--check only)",
223
+ )
224
+ args = parser.parse_args()
225
+
226
+ if args.check and not args.skill:
227
+ parser.error("--skill is required when using --check")
228
+
229
+ if args.compute:
230
+ return compute(args.state_file)
231
+ return check(args.state_file, args.skill, force=args.force)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ raise SystemExit(main())
@@ -0,0 +1,119 @@
1
+ # /// script
2
+ # requires-python = ">=3.9"
3
+ # dependencies = []
4
+ # ///
5
+ """Campaign Parse Manifest — deterministic parse of a --manifest target list.
6
+
7
+ step-01 (Setup) seeds a headless campaign's targets from a plain-text manifest.
8
+ Parsing that format by hand risks silently dropping a malformed line — exactly
9
+ the kind of mechanical, all-or-nothing work that belongs in a script. This
10
+ parser is the single source of truth for the format documented in SKILL.md
11
+ "On Activation".
12
+
13
+ Format (one target per line):
14
+
15
+ name,repo_url,tier,pin[;dep1,dep2,...]
16
+
17
+ - `pin` may be empty (latest): `name,repo_url,tier,` or `name,repo_url,tier`
18
+ - a trailing `;`-segment lists depends_on names (comma-separated)
19
+ - blank lines and lines starting with `#` are skipped
20
+ - `tier` must be `A` or `B`
21
+
22
+ CLI:
23
+ uv run campaign-parse-manifest.py <path/to/manifest.txt>
24
+ cat manifest.txt | uv run campaign-parse-manifest.py -
25
+
26
+ Output (JSON on stdout):
27
+ {"targets": [{"name","repo_url","tier","pin","depends_on"}], "errors": [{"line","message"}]}
28
+
29
+ Exit codes:
30
+ 0 parsed cleanly (no errors)
31
+ 1 one or more malformed lines (errors[] populated; targets[] omits bad lines)
32
+ 2 file error (not found / unreadable)
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import json
39
+ import sys
40
+ from pathlib import Path
41
+ from typing import Any, Dict, List
42
+
43
+
44
+ def parse_manifest_text(text: str) -> Dict[str, Any]:
45
+ targets: List[Dict[str, Any]] = []
46
+ errors: List[Dict[str, Any]] = []
47
+ seen: set = set()
48
+
49
+ for lineno, raw in enumerate(text.splitlines(), start=1):
50
+ line = raw.strip()
51
+ if not line or line.startswith("#"):
52
+ continue
53
+
54
+ body, sep, deps_part = line.partition(";")
55
+ fields = [f.strip() for f in body.split(",")]
56
+ if len(fields) < 3:
57
+ errors.append({"line": lineno, "message": f"expected `name,repo_url,tier[,pin]`, got {len(fields)} field(s)"})
58
+ continue
59
+
60
+ name, repo_url, tier = fields[0], fields[1], fields[2]
61
+ pin = fields[3] if len(fields) >= 4 and fields[3] != "" else None
62
+
63
+ if not name:
64
+ errors.append({"line": lineno, "message": "empty `name`"})
65
+ continue
66
+ if not repo_url:
67
+ errors.append({"line": lineno, "message": f"`{name}` has empty `repo_url`"})
68
+ continue
69
+ if tier not in ("A", "B"):
70
+ errors.append({"line": lineno, "message": f"`{name}` has invalid tier `{tier}` (must be A or B)"})
71
+ continue
72
+ if name in seen:
73
+ errors.append({"line": lineno, "message": f"duplicate target name `{name}`"})
74
+ continue
75
+ seen.add(name)
76
+
77
+ depends_on = [d.strip() for d in deps_part.split(",") if d.strip()] if sep else []
78
+
79
+ targets.append(
80
+ {"name": name, "repo_url": repo_url, "tier": tier, "pin": pin, "depends_on": depends_on}
81
+ )
82
+
83
+ return {"targets": targets, "errors": errors}
84
+
85
+
86
+ def run(path: str) -> int:
87
+ if path == "-":
88
+ text = sys.stdin.read()
89
+ else:
90
+ p = Path(path)
91
+ if not p.is_file():
92
+ json.dump({"error": f"Manifest not found: {path}", "code": "MANIFEST_NOT_FOUND"}, sys.stderr)
93
+ sys.stderr.write("\n")
94
+ return 2
95
+ try:
96
+ text = p.read_text(encoding="utf-8")
97
+ except OSError as exc:
98
+ json.dump({"error": f"Manifest unreadable: {exc}", "code": "MANIFEST_READ_ERROR"}, sys.stderr)
99
+ sys.stderr.write("\n")
100
+ return 2
101
+
102
+ result = parse_manifest_text(text)
103
+ json.dump(result, sys.stdout, separators=(",", ":"))
104
+ sys.stdout.write("\n")
105
+ return 1 if result["errors"] else 0
106
+
107
+
108
+ def main(argv: list[str] | None = None) -> int:
109
+ parser = argparse.ArgumentParser(
110
+ prog="campaign-parse-manifest",
111
+ description="Parse a --manifest target list into structured targets.",
112
+ )
113
+ parser.add_argument("path", help="path to manifest text file, or `-` for stdin")
114
+ args = parser.parse_args(argv)
115
+ return run(args.path)
116
+
117
+
118
+ if __name__ == "__main__":
119
+ raise SystemExit(main())