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.
- 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 +42 -3
- 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 +9 -3
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +73 -27
- package/package.json +2 -2
- package/src/module-help.csv +2 -1
- package/src/shared/_known-workarounds.yaml +155 -0
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +14 -2
- package/src/shared/scripts/schemas/skf-brief-result-envelope.v1.json +6 -1
- 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 +417 -0
- package/src/shared/scripts/skf-detect-language.py +5 -3
- package/src/shared/scripts/skf-emit-brief-result-envelope.py +12 -1
- 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 +192 -0
- package/src/shared/scripts/skf-shape-detect.py +1114 -0
- package/src/shared/scripts/skf-validate-brief-schema.py +2 -5
- package/src/shared/scripts/skf-validate-pins.py +368 -0
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +26 -20
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/init.md +30 -0
- package/src/skf-analyze-source/references/step-auto-scope.md +677 -0
- package/src/skf-analyze-source/references/step-shape-detect.md +86 -0
- package/src/skf-audit-skill/SKILL.md +1 -0
- package/src/skf-audit-skill/assets/drift-report-template.md +6 -0
- package/src/skf-audit-skill/references/init.md +1 -1
- package/src/skf-audit-skill/references/report.md +4 -0
- package/src/skf-audit-skill/references/severity-classify.md +1 -1
- package/src/skf-audit-skill/references/step-doc-drift.md +147 -0
- package/src/skf-brief-skill/SKILL.md +7 -3
- 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 +22 -2
- package/src/skf-brief-skill/references/scope-definition.md +2 -1
- package/src/skf-brief-skill/references/step-auto-brief.md +188 -0
- package/src/skf-brief-skill/references/step-auto-validate.md +209 -0
- 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 +4 -1
- package/src/skf-create-skill/assets/compile-assembly-rules.md +22 -0
- package/src/skf-create-skill/assets/skill-sections.md +2 -0
- package/src/skf-create-skill/references/compile.md +5 -2
- 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-auto-shard.md +110 -0
- package/src/skf-create-skill/references/step-doc-rot.md +109 -0
- package/src/skf-create-skill/references/step-doc-sources.md +123 -0
- 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 +15 -4
- 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/SKILL.md +8 -7
- package/src/skf-test-skill/customize.toml +2 -1
- package/src/skf-test-skill/references/coverage-check.md +10 -0
- package/src/skf-test-skill/references/external-validators.md +1 -1
- package/src/skf-test-skill/references/init.md +16 -1
- package/src/skf-test-skill/references/report.md +5 -1
- package/src/skf-test-skill/references/score.md +95 -6
- package/src/skf-test-skill/references/source-access-protocol.md +13 -3
- package/src/skf-test-skill/references/step-hard-gate.md +73 -0
- package/src/skf-test-skill/scripts/compute-score.py +4 -3
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- 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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
nextStepFile: 'health-check.md'
|
|
3
|
+
stateSchemaFile: 'assets/campaign-state-schema.json'
|
|
4
|
+
stateFile: '{campaignWorkspacePath}/_campaign-state.yaml'
|
|
5
|
+
backupFile: '{campaignWorkspacePath}/_campaign-state.yaml.bak'
|
|
6
|
+
reportFile: '{campaignWorkspacePath}/campaign-report.md'
|
|
7
|
+
reportScript: 'scripts/campaign-report.py'
|
|
8
|
+
reportTemplate: '{reportTemplatePath}'
|
|
9
|
+
validateScript: 'scripts/campaign-validate-state.py'
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
<!-- Config: communicate in {communication_language}. -->
|
|
13
|
+
|
|
14
|
+
# Maintenance
|
|
15
|
+
|
|
16
|
+
## STEP GOAL:
|
|
17
|
+
|
|
18
|
+
Generate a comprehensive campaign report from the accumulated state, emit the headless result envelope, and chain to the shared health check as the campaign's terminal step.
|
|
19
|
+
|
|
20
|
+
## RULES
|
|
21
|
+
|
|
22
|
+
- This step uses the **read-backup-modify-write** pattern.
|
|
23
|
+
- Validate state on load via `uv run {validateScript} --state-file {stateFile}`; HALT (exit 3) on non-zero.
|
|
24
|
+
- Update `campaign.last_updated` to current ISO-8601 with timezone on every write.
|
|
25
|
+
- Update `campaign.current_stage` to `10`.
|
|
26
|
+
- If `{headless_mode}` is true, auto-proceed through confirmation gates. Emit the headless envelope on stdout.
|
|
27
|
+
|
|
28
|
+
## TASKS
|
|
29
|
+
|
|
30
|
+
### §1 — Read + Validate State
|
|
31
|
+
|
|
32
|
+
Load `{stateFile}`. Run `uv run {validateScript} --state-file {stateFile}`; on non-zero, HALT (exit 3) with the script's `errors[]`.
|
|
33
|
+
|
|
34
|
+
### §2 — Generate Campaign Report
|
|
35
|
+
|
|
36
|
+
Invoke the campaign report script:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
uv run {reportScript} \
|
|
40
|
+
--state-file {stateFile} \
|
|
41
|
+
--template-file {reportTemplate} \
|
|
42
|
+
--output-file {reportFile}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Capture the JSON result from stdout — it carries `skills_completed`, `skills_failed`, `quality_scores`, and `duration` already computed. Do not recompute these by hand in §3.
|
|
46
|
+
|
|
47
|
+
**On success** (exit code 0): log the report path and summary stats from the result JSON.
|
|
48
|
+
|
|
49
|
+
**On failure** (exit code 2): the campaign itself has already completed — do NOT discard it over a missing summary artifact. Display the error from stderr, log "report generation failed (degraded): {error}" to the decision log, set the envelope's `campaign_report_path` to `null`, and CONTINUE to §3. Surface `report-failure` only as a degraded signal in the envelope (`status:"error"`, `exit_code:10`), never as a hard halt that throws away a finished campaign.
|
|
50
|
+
|
|
51
|
+
Display: "**Campaign report generated:** `{reportFile}`" (or, on degrade, "**Campaign complete — report generation failed (see decision log); state is intact.**")
|
|
52
|
+
|
|
53
|
+
**Optional post-completion hook:** if `{onComplete}` (resolved in On Activation) is non-empty, invoke `{onComplete} --report-path={reportFile}`. Log the outcome to the decision log; a hook failure is recorded but never fails the campaign.
|
|
54
|
+
|
|
55
|
+
### §3 — Emit Headless Envelope
|
|
56
|
+
|
|
57
|
+
When `{headless_mode}` is true, emit the campaign result envelope on stdout, copying the counts, `quality_scores`, and `duration` straight from the §2 report-script result (do not recompute):
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
SKF_CAMPAIGN_RESULT_JSON: {"status":"success","skills_completed":N,"skills_failed":N,"quality_scores":{...},"campaign_report_path":"{reportFile}","decision_log":"{campaignWorkspacePath}/_campaign-decision-log.md","duration":"..."}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
- `status`: "success" if the campaign completed normally (HARD HALTs emit the error variant per SKILL.md "Result Contract on HARD HALT")
|
|
64
|
+
- `skills_completed` / `skills_failed` / `quality_scores` / `duration`: from the §2 report-script result JSON
|
|
65
|
+
- `campaign_report_path`: `{reportFile}`
|
|
66
|
+
- `decision_log`: path to the append-only decision log
|
|
67
|
+
|
|
68
|
+
When not in headless mode, skip this section silently.
|
|
69
|
+
|
|
70
|
+
### §4 — Stage Completion
|
|
71
|
+
|
|
72
|
+
Set `campaign.current_stage` to `10`. Update `campaign.last_updated` to current ISO-8601 with timezone. Backup `{stateFile}` to `{backupFile}`, then write the updated state.
|
|
73
|
+
|
|
74
|
+
### §5 — Chain to Health Check
|
|
75
|
+
|
|
76
|
+
The operator's findings-routing consent (`campaign.health_findings_queue`) is applied by the terminal health-check step: `references/health-check.md` §1 reads it from state and carries it into the shared health check as the pre-decided opt-in (`"improvement"` → route non-bug findings to the shared improvement queue without re-prompting; `"local"` → local queue only). That is what makes the §2 setup consent prompt actionable rather than cosmetic — surface the active setting here for visibility.
|
|
77
|
+
|
|
78
|
+
Display: "**Campaign complete.** Report at `{reportFile}`. Findings routing: {campaign.health_findings_queue}. Chaining to health check..."
|
|
79
|
+
|
|
80
|
+
Chain to `{nextStepFile}` (shared/health-check.md).
|
|
81
|
+
|
|
82
|
+
## OUTPUT
|
|
83
|
+
|
|
84
|
+
Display campaign completion summary: skills completed, skills failed, report path, total duration. Chain to `{nextStepFile}`.
|
|
@@ -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())
|