bmad-module-skill-forge 1.9.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.nvmrc +1 -1
- package/README.md +10 -5
- package/docs/_data/pinned.yaml +2 -2
- package/docs/agents.md +11 -2
- package/docs/architecture.md +5 -4
- package/docs/bmad-synergy.md +30 -7
- package/docs/campaign.md +172 -0
- package/docs/examples.md +7 -3
- package/docs/forge-auto.md +90 -0
- package/docs/getting-started.md +9 -0
- package/docs/how-it-works.md +6 -4
- package/docs/index.md +4 -3
- package/docs/skill-model.md +1 -1
- package/docs/troubleshooting.md +17 -1
- package/docs/verifying-a-skill.md +2 -2
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +64 -24
- package/package.json +2 -2
- package/src/knowledge/skill-lifecycle.md +1 -1
- package/src/module-help.csv +4 -3
- package/src/shared/data/language-corpora.json +32 -0
- package/src/shared/references/pipeline-contracts.md +6 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +6 -1
- package/src/shared/scripts/skf-comention-pairs.py +350 -0
- package/src/shared/scripts/skf-count-tokens.py +222 -0
- package/src/shared/scripts/skf-derive-assembly-shape.py +107 -0
- package/src/shared/scripts/skf-detect-docs.py +240 -4
- package/src/shared/scripts/skf-detect-language.py +82 -3
- package/src/shared/scripts/skf-detect-tools.py +105 -2
- package/src/shared/scripts/skf-emit-result-envelope.py +17 -0
- package/src/shared/scripts/skf-enumerate-stack-skills.py +111 -2
- package/src/shared/scripts/skf-extract-public-api.py +53 -0
- package/src/shared/scripts/skf-find-cycles.py +246 -0
- package/src/shared/scripts/skf-hash-content.py +487 -2
- package/src/shared/scripts/skf-language-corpora.py +100 -0
- package/src/shared/scripts/skf-manifest-ops.py +67 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +244 -0
- package/src/shared/scripts/skf-preapply.py +1 -1
- package/src/shared/scripts/skf-rebuild-managed-sections.py +180 -0
- package/src/shared/scripts/skf-recommend-scope-type.py +1 -2
- package/src/shared/scripts/skf-render-metadata-stats.py +499 -0
- package/src/shared/scripts/skf-rewrite-skill-name.py +308 -0
- package/src/shared/scripts/skf-shape-detect.py +576 -25
- package/src/shared/scripts/skf-shard-body.py +461 -0
- package/src/shared/scripts/skf-skill-inventory.py +139 -3
- package/src/shared/scripts/skf-structural-diff.py +307 -67
- package/src/shared/scripts/skf-validate-brief-schema.py +41 -6
- package/src/shared/scripts/skf-validate-feasibility-report.py +234 -0
- package/src/shared/scripts/skf-validate-frontmatter.py +17 -12
- package/src/shared/scripts/skf-validate-output.py +443 -10
- package/src/shared/scripts/skf-validate-pins.py +3 -3
- package/src/shared/scripts/skf-verify-no-trace.py +214 -0
- package/src/shared/scripts/skf-verify-provenance-completeness.py +439 -0
- package/src/shared/scripts/skf-write-skill-brief.py +21 -5
- package/src/skf-analyze-source/SKILL.md +17 -32
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/customize.toml +2 -0
- package/src/skf-analyze-source/references/auto-docs-only.md +116 -0
- package/src/skf-analyze-source/references/continue.md +8 -13
- package/src/skf-analyze-source/references/generate-briefs.md +28 -12
- package/src/skf-analyze-source/references/headless-contract.md +33 -0
- package/src/skf-analyze-source/references/health-check.md +1 -2
- package/src/skf-analyze-source/references/identify-units.md +25 -36
- package/src/skf-analyze-source/references/init.md +12 -25
- package/src/skf-analyze-source/references/map-and-detect.md +8 -20
- package/src/skf-analyze-source/references/recommend.md +4 -14
- package/src/skf-analyze-source/references/scan-project.md +3 -11
- package/src/skf-analyze-source/references/step-auto-scope.md +211 -197
- package/src/skf-analyze-source/references/step-shape-detect.md +20 -13
- package/src/skf-analyze-source/references/unit-detection-heuristics.md +2 -2
- package/src/skf-audit-skill/SKILL.md +9 -6
- package/src/skf-audit-skill/customize.toml +2 -0
- package/src/skf-audit-skill/references/init.md +58 -32
- package/src/skf-audit-skill/references/re-index.md +15 -34
- package/src/skf-audit-skill/references/report.md +11 -58
- package/src/skf-audit-skill/references/semantic-diff.md +4 -22
- package/src/skf-audit-skill/references/severity-classify.md +53 -87
- package/src/skf-audit-skill/references/step-doc-drift.md +49 -19
- package/src/skf-audit-skill/references/structural-diff.md +43 -68
- package/src/skf-brief-skill/SKILL.md +7 -36
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +1 -1
- package/src/skf-brief-skill/customize.toml +14 -0
- package/src/skf-brief-skill/references/analyze-target.md +13 -18
- package/src/skf-brief-skill/references/confirm-brief.md +2 -15
- package/src/skf-brief-skill/references/draft-checkpoint.md +8 -8
- package/src/skf-brief-skill/references/gather-intent.md +27 -24
- package/src/skf-brief-skill/references/headless-args.md +1 -1
- package/src/skf-brief-skill/references/health-check.md +1 -22
- package/src/skf-brief-skill/references/invocation-contract.md +37 -0
- package/src/skf-brief-skill/references/scope-definition.md +6 -15
- package/src/skf-brief-skill/references/step-auto-brief.md +22 -9
- package/src/skf-brief-skill/references/step-auto-validate.md +3 -7
- package/src/skf-brief-skill/references/version-resolution.md +1 -1
- package/src/skf-brief-skill/references/write-brief.md +19 -13
- package/src/skf-campaign/SKILL.md +121 -0
- package/src/skf-campaign/assets/campaign-state-schema.json +191 -0
- package/src/skf-campaign/customize.toml +75 -0
- package/src/skf-campaign/manifest.yaml +15 -0
- package/src/skf-campaign/references/campaign-contracts.md +67 -0
- package/src/skf-campaign/references/campaign-directive-spec.md +32 -0
- package/src/skf-campaign/references/health-check.md +35 -0
- package/src/skf-campaign/references/step-01-setup.md +121 -0
- package/src/skf-campaign/references/step-02-strategy.md +102 -0
- package/src/skf-campaign/references/step-03-pins.md +56 -0
- package/src/skf-campaign/references/step-04-provenance.md +63 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +103 -0
- package/src/skf-campaign/references/step-06-batch.md +88 -0
- package/src/skf-campaign/references/step-07-capstone.md +63 -0
- package/src/skf-campaign/references/step-08-verify.md +75 -0
- package/src/skf-campaign/references/step-09-refine.md +83 -0
- package/src/skf-campaign/references/step-10-export.md +106 -0
- package/src/skf-campaign/references/step-11-maintenance.md +84 -0
- package/src/skf-campaign/references/step-resume.md +115 -0
- package/src/skf-campaign/scripts/.gitkeep +0 -0
- package/src/skf-campaign/scripts/campaign-deps.py +244 -0
- package/src/skf-campaign/scripts/campaign-parse-manifest.py +119 -0
- package/src/skf-campaign/scripts/campaign-provenance.py +247 -0
- package/src/skf-campaign/scripts/campaign-render-batch.py +199 -0
- package/src/skf-campaign/scripts/campaign-render-kickoff.py +158 -0
- package/src/skf-campaign/scripts/campaign-report.py +249 -0
- package/src/skf-campaign/scripts/campaign-status.py +162 -0
- package/src/skf-campaign/scripts/campaign-validate-pins.py +174 -0
- package/src/skf-campaign/scripts/campaign-validate-state.py +207 -0
- package/src/skf-campaign/templates/campaign-brief-template.yaml +34 -0
- package/src/skf-campaign/templates/campaign-report-template.md +54 -0
- package/src/skf-campaign/templates/kickoff-template.md +48 -0
- package/src/skf-create-skill/SKILL.md +15 -4
- package/src/skf-create-skill/assets/compile-assembly-rules.md +35 -34
- package/src/skf-create-skill/assets/skill-sections.md +9 -100
- package/src/skf-create-skill/assets/tessl-dismissal-rules.md +4 -13
- package/src/skf-create-skill/customize.toml +13 -0
- package/src/skf-create-skill/references/authoritative-files-protocol.md +7 -7
- package/src/skf-create-skill/references/compile.md +37 -47
- package/src/skf-create-skill/references/component-extraction.md +8 -35
- package/src/skf-create-skill/references/ecosystem-check.md +9 -25
- package/src/skf-create-skill/references/enrich.md +4 -17
- package/src/skf-create-skill/references/extract.md +20 -27
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +1 -1
- package/src/skf-create-skill/references/extraction-patterns.md +10 -8
- package/src/skf-create-skill/references/generate-artifacts.md +20 -25
- package/src/skf-create-skill/references/health-check.md +2 -2
- package/src/skf-create-skill/references/load-brief.md +16 -18
- package/src/skf-create-skill/references/report.md +27 -14
- package/src/skf-create-skill/references/source-resolution-protocols.md +4 -4
- package/src/skf-create-skill/references/step-auto-shard.md +52 -25
- package/src/skf-create-skill/references/step-doc-rot.md +33 -15
- package/src/skf-create-skill/references/step-doc-sources.md +13 -4
- package/src/skf-create-skill/references/sub/ccc-discover.md +2 -13
- package/src/skf-create-skill/references/sub/fetch-docs.md +33 -46
- package/src/skf-create-skill/references/sub/fetch-temporal.md +5 -20
- package/src/skf-create-skill/references/tier-degradation-rules.md +6 -8
- package/src/skf-create-skill/references/validate.md +69 -29
- package/src/skf-create-skill/scripts/scan-doc-rot.py +245 -0
- package/src/skf-create-stack-skill/SKILL.md +15 -9
- package/src/skf-create-stack-skill/assets/provenance-map-schema.md +1 -1
- package/src/skf-create-stack-skill/customize.toml +10 -0
- package/src/skf-create-stack-skill/references/compile-stack.md +7 -8
- package/src/skf-create-stack-skill/references/compose-mode-rules.md +2 -13
- package/src/skf-create-stack-skill/references/detect-integrations.md +49 -54
- package/src/skf-create-stack-skill/references/detect-manifests.md +37 -30
- package/src/skf-create-stack-skill/references/generate-output.md +24 -75
- package/src/skf-create-stack-skill/references/init.md +9 -81
- package/src/skf-create-stack-skill/references/manifest-patterns.md +5 -35
- package/src/skf-create-stack-skill/references/parallel-extract.md +12 -27
- package/src/skf-create-stack-skill/references/rank-and-confirm.md +6 -6
- package/src/skf-create-stack-skill/references/report.md +27 -62
- package/src/skf-create-stack-skill/references/validate.md +35 -31
- package/src/skf-drop-skill/SKILL.md +20 -23
- package/src/skf-drop-skill/customize.toml +10 -0
- package/src/skf-drop-skill/references/execute.md +67 -36
- package/src/skf-drop-skill/references/headless-contract.md +18 -0
- package/src/skf-drop-skill/references/health-check.md +1 -13
- package/src/skf-drop-skill/references/report.md +44 -10
- package/src/skf-drop-skill/references/select.md +93 -48
- package/src/skf-drop-skill/scripts/dir-sizes.py +116 -0
- package/src/skf-export-skill/SKILL.md +17 -14
- package/src/skf-export-skill/assets/managed-section-format.md +2 -5
- package/src/skf-export-skill/customize.toml +10 -6
- package/src/skf-export-skill/references/generate-snippet.md +21 -35
- package/src/skf-export-skill/references/load-skill.md +44 -42
- package/src/skf-export-skill/references/multi-skill-mode.md +1 -1
- package/src/skf-export-skill/references/orphan-context-detection.md +11 -3
- package/src/skf-export-skill/references/package.md +27 -33
- package/src/skf-export-skill/references/preflight-snippet-root-probe.md +24 -5
- package/src/skf-export-skill/references/result-envelope.md +28 -0
- package/src/skf-export-skill/references/summary.md +11 -5
- package/src/skf-export-skill/references/token-report.md +42 -24
- package/src/skf-export-skill/references/update-context.md +32 -33
- package/src/skf-forger/SKILL.md +32 -39
- package/src/skf-forger/references/pipeline-mode.md +34 -0
- package/src/skf-forger/scripts/parse-pipeline.py +270 -0
- package/src/skf-quick-skill/SKILL.md +10 -60
- package/src/skf-quick-skill/customize.toml +9 -0
- package/src/skf-quick-skill/references/batch-mode.md +6 -4
- package/src/skf-quick-skill/references/compile.md +15 -52
- package/src/skf-quick-skill/references/ecosystem-check.md +5 -20
- package/src/skf-quick-skill/references/finalize.md +12 -6
- package/src/skf-quick-skill/references/halt-contract.md +54 -0
- package/src/skf-quick-skill/references/health-check.md +3 -4
- package/src/skf-quick-skill/references/quick-extract.md +7 -18
- package/src/skf-quick-skill/references/registry-resolution.md +1 -23
- package/src/skf-quick-skill/references/resolve-target.md +27 -34
- package/src/skf-quick-skill/references/write-and-validate.md +10 -25
- package/src/skf-refine-architecture/SKILL.md +16 -8
- package/src/skf-refine-architecture/customize.toml +9 -0
- package/src/skf-refine-architecture/references/compile.md +10 -8
- package/src/skf-refine-architecture/references/gap-analysis.md +21 -50
- package/src/skf-refine-architecture/references/health-check.md +1 -1
- package/src/skf-refine-architecture/references/improvements.md +9 -25
- package/src/skf-refine-architecture/references/init.md +9 -16
- package/src/skf-refine-architecture/references/issue-detection.md +10 -25
- package/src/skf-refine-architecture/references/refinement-rules.md +4 -16
- package/src/skf-refine-architecture/references/report.md +14 -11
- package/src/skf-rename-skill/SKILL.md +11 -21
- package/src/skf-rename-skill/customize.toml +10 -0
- package/src/skf-rename-skill/references/execute.md +112 -82
- package/src/skf-rename-skill/references/exit-codes.md +12 -0
- package/src/skf-rename-skill/references/report.md +16 -8
- package/src/skf-rename-skill/references/select.md +69 -42
- package/src/skf-rename-skill/scripts/skf-validate-rename-name.py +161 -0
- package/src/skf-setup/SKILL.md +21 -13
- package/src/skf-setup/customize.toml +10 -0
- package/src/skf-setup/references/auto-index.md +11 -10
- package/src/skf-setup/references/ccc-index.md +14 -15
- package/src/skf-setup/references/detect-and-tier.md +10 -8
- package/src/skf-setup/references/report.md +26 -32
- package/src/skf-setup/references/tier-rules.md +2 -32
- package/src/skf-setup/references/write-config.md +9 -10
- package/src/skf-test-skill/SKILL.md +10 -8
- package/src/skf-test-skill/customize.toml +2 -0
- package/src/skf-test-skill/references/coherence-check.md +19 -31
- package/src/skf-test-skill/references/coverage-check.md +111 -67
- package/src/skf-test-skill/references/detect-mode.md +10 -41
- package/src/skf-test-skill/references/external-validators.md +11 -19
- package/src/skf-test-skill/references/init.md +39 -21
- package/src/skf-test-skill/references/migration-section-rules.md +4 -4
- package/src/skf-test-skill/references/report.md +60 -40
- package/src/skf-test-skill/references/score.md +32 -64
- package/src/skf-test-skill/references/scoring-rules.md +6 -62
- package/src/skf-test-skill/references/source-access-protocol.md +15 -5
- package/src/skf-test-skill/references/step-hard-gate.md +1 -3
- package/src/skf-test-skill/scripts/aggregate-coherence.py +276 -0
- package/src/skf-test-skill/scripts/check-metadata-coherence.py +359 -0
- package/src/skf-test-skill/scripts/combine-external-scores.py +190 -0
- package/src/skf-test-skill/scripts/compute-score.py +135 -4
- package/src/skf-test-skill/scripts/reconcile-coverage.py +327 -0
- package/src/skf-test-skill/scripts/validate-inventory.py +251 -0
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +192 -0
- package/src/skf-update-skill/SKILL.md +17 -5
- package/src/skf-update-skill/customize.toml +11 -6
- package/src/skf-update-skill/references/detect-changes.md +48 -36
- package/src/skf-update-skill/references/health-check.md +5 -11
- package/src/skf-update-skill/references/init.md +40 -32
- package/src/skf-update-skill/references/manual-section-rules.md +1 -2
- package/src/skf-update-skill/references/merge-conflict-rules.md +1 -52
- package/src/skf-update-skill/references/merge.md +13 -57
- package/src/skf-update-skill/references/re-extract.md +27 -25
- package/src/skf-update-skill/references/report.md +14 -7
- package/src/skf-update-skill/references/validate.md +32 -30
- package/src/skf-update-skill/references/write.md +86 -51
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +167 -0
- package/src/skf-verify-stack/SKILL.md +11 -21
- package/src/skf-verify-stack/customize.toml +12 -3
- package/src/skf-verify-stack/references/coverage-patterns.md +1 -29
- package/src/skf-verify-stack/references/coverage.md +21 -10
- package/src/skf-verify-stack/references/exit-codes.md +14 -0
- package/src/skf-verify-stack/references/health-check.md +1 -1
- package/src/skf-verify-stack/references/init.md +20 -25
- package/src/skf-verify-stack/references/integration-verification-rules.md +10 -19
- package/src/skf-verify-stack/references/integrations.md +44 -49
- package/src/skf-verify-stack/references/report.md +28 -34
- package/src/skf-verify-stack/references/requirements.md +3 -3
- package/src/skf-verify-stack/references/synthesize.md +36 -39
- package/src/skf-verify-stack/scripts/skf-coverage-tally.py +196 -0
- package/src/skf-verify-stack/scripts/skf-report-delta.py +256 -0
- package/src/skf-verify-stack/scripts/skf-verdict-rollup.py +258 -0
- package/tools/cli/lib/ui.js +3 -2
- package/docs/deepwiki.md +0 -89
- package/src/skf-rename-skill/references/rebuild-context.md +0 -110
|
@@ -0,0 +1,244 @@
|
|
|
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,
|
|
19
|
+
"cycle_participants": [...] | null, "tier_counts": {"A": int, "B": int}}
|
|
20
|
+
|
|
21
|
+
--check:
|
|
22
|
+
{"skill": "name", "ready": bool, "unmet_deps": [...], "forced": bool}
|
|
23
|
+
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 success / ready / force-override
|
|
26
|
+
1 circular deps / unmet deps / dangling reference
|
|
27
|
+
2 error (missing file, bad YAML)
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import sys
|
|
35
|
+
import heapq
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Dict, List, Optional
|
|
38
|
+
|
|
39
|
+
import yaml
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _emit_error(message: str, code: str) -> None:
|
|
43
|
+
json.dump({"error": message, "code": code}, sys.stderr)
|
|
44
|
+
sys.stderr.write("\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load_yaml(path: Path) -> Any:
|
|
48
|
+
with open(path, encoding="utf-8") as f:
|
|
49
|
+
return yaml.safe_load(f)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _build_skill_map(skills: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
|
|
53
|
+
return {s["name"]: s for s in skills}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_deps(
|
|
57
|
+
skill_map: Dict[str, Dict[str, Any]],
|
|
58
|
+
) -> Optional[List[str]]:
|
|
59
|
+
dangling: List[str] = []
|
|
60
|
+
for name, skill in skill_map.items():
|
|
61
|
+
for dep in skill.get("depends_on", []) or []:
|
|
62
|
+
if dep not in skill_map:
|
|
63
|
+
dangling.append(f"{name} depends on unknown skill '{dep}'")
|
|
64
|
+
return dangling if dangling else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def compute(state_file: str) -> int:
|
|
68
|
+
path = Path(state_file)
|
|
69
|
+
if not path.is_file():
|
|
70
|
+
_emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
state = _load_yaml(path)
|
|
75
|
+
except Exception as exc:
|
|
76
|
+
_emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
|
|
77
|
+
return 2
|
|
78
|
+
|
|
79
|
+
skills = state.get("skills", [])
|
|
80
|
+
if not isinstance(skills, list):
|
|
81
|
+
_emit_error("State file 'skills' is not an array", "INVALID_STATE")
|
|
82
|
+
return 2
|
|
83
|
+
|
|
84
|
+
skill_map = _build_skill_map(skills)
|
|
85
|
+
|
|
86
|
+
tier_counts = {"A": 0, "B": 0}
|
|
87
|
+
for skill in skill_map.values():
|
|
88
|
+
tier = skill.get("tier")
|
|
89
|
+
if tier in tier_counts:
|
|
90
|
+
tier_counts[tier] += 1
|
|
91
|
+
|
|
92
|
+
dangling = _validate_deps(skill_map)
|
|
93
|
+
if dangling:
|
|
94
|
+
_emit_error(
|
|
95
|
+
f"Dangling dependency references: {'; '.join(dangling)}",
|
|
96
|
+
"DANGLING_DEPENDENCY",
|
|
97
|
+
)
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
in_degree: Dict[str, int] = {name: 0 for name in skill_map}
|
|
101
|
+
adjacency: Dict[str, List[str]] = {name: [] for name in skill_map}
|
|
102
|
+
|
|
103
|
+
for name, skill in skill_map.items():
|
|
104
|
+
for dep in skill.get("depends_on", []) or []:
|
|
105
|
+
adjacency[dep].append(name)
|
|
106
|
+
in_degree[name] += 1
|
|
107
|
+
|
|
108
|
+
def _tier_key(n: str) -> tuple[int, str]:
|
|
109
|
+
return (0 if skill_map[n].get("tier") == "A" else 1, n)
|
|
110
|
+
|
|
111
|
+
heap: List[tuple[int, str]] = []
|
|
112
|
+
for n in skill_map:
|
|
113
|
+
if in_degree[n] == 0:
|
|
114
|
+
heapq.heappush(heap, _tier_key(n))
|
|
115
|
+
|
|
116
|
+
execution_order: List[str] = []
|
|
117
|
+
|
|
118
|
+
while heap:
|
|
119
|
+
_, current = heapq.heappop(heap)
|
|
120
|
+
execution_order.append(current)
|
|
121
|
+
|
|
122
|
+
for dependent in adjacency[current]:
|
|
123
|
+
in_degree[dependent] -= 1
|
|
124
|
+
if in_degree[dependent] == 0:
|
|
125
|
+
heapq.heappush(heap, _tier_key(dependent))
|
|
126
|
+
|
|
127
|
+
if len(execution_order) < len(skill_map):
|
|
128
|
+
cycle_participants = sorted(
|
|
129
|
+
n for n in skill_map if n not in set(execution_order)
|
|
130
|
+
)
|
|
131
|
+
output = {
|
|
132
|
+
"execution_order": execution_order,
|
|
133
|
+
"circular_deps_detected": True,
|
|
134
|
+
"cycle_participants": cycle_participants,
|
|
135
|
+
"tier_counts": tier_counts,
|
|
136
|
+
}
|
|
137
|
+
json.dump(output, sys.stdout, separators=(",", ":"))
|
|
138
|
+
sys.stdout.write("\n")
|
|
139
|
+
return 1
|
|
140
|
+
|
|
141
|
+
output = {
|
|
142
|
+
"execution_order": execution_order,
|
|
143
|
+
"circular_deps_detected": False,
|
|
144
|
+
"cycle_participants": None,
|
|
145
|
+
"tier_counts": tier_counts,
|
|
146
|
+
}
|
|
147
|
+
json.dump(output, sys.stdout, separators=(",", ":"))
|
|
148
|
+
sys.stdout.write("\n")
|
|
149
|
+
return 0
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def check(state_file: str, skill_name: str, force: bool = False) -> int:
|
|
153
|
+
path = Path(state_file)
|
|
154
|
+
if not path.is_file():
|
|
155
|
+
_emit_error(f"State file not found: {state_file}", "STATE_NOT_FOUND")
|
|
156
|
+
return 2
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
state = _load_yaml(path)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
_emit_error(f"Failed to parse state file: {exc}", "STATE_PARSE_ERROR")
|
|
162
|
+
return 2
|
|
163
|
+
|
|
164
|
+
skills = state.get("skills", [])
|
|
165
|
+
if not isinstance(skills, list):
|
|
166
|
+
_emit_error("State file 'skills' is not an array", "INVALID_STATE")
|
|
167
|
+
return 2
|
|
168
|
+
|
|
169
|
+
skill_map = _build_skill_map(skills)
|
|
170
|
+
|
|
171
|
+
if skill_name not in skill_map:
|
|
172
|
+
_emit_error(f"Skill '{skill_name}' not found in state", "SKILL_NOT_FOUND")
|
|
173
|
+
return 2
|
|
174
|
+
|
|
175
|
+
deps = skill_map[skill_name].get("depends_on", []) or []
|
|
176
|
+
unmet: List[str] = []
|
|
177
|
+
for dep in deps:
|
|
178
|
+
if dep not in skill_map:
|
|
179
|
+
_emit_error(
|
|
180
|
+
f"Skill '{skill_name}' depends on unknown skill '{dep}'",
|
|
181
|
+
"DANGLING_DEPENDENCY",
|
|
182
|
+
)
|
|
183
|
+
return 1
|
|
184
|
+
if skill_map[dep].get("status") != "completed":
|
|
185
|
+
unmet.append(dep)
|
|
186
|
+
|
|
187
|
+
ready = len(unmet) == 0
|
|
188
|
+
forced = force and not ready
|
|
189
|
+
|
|
190
|
+
if forced:
|
|
191
|
+
json.dump(
|
|
192
|
+
{"warning": f"Forcing past unmet dependencies for '{skill_name}'", "unmet": unmet},
|
|
193
|
+
sys.stderr,
|
|
194
|
+
)
|
|
195
|
+
sys.stderr.write("\n")
|
|
196
|
+
|
|
197
|
+
output = {
|
|
198
|
+
"skill": skill_name,
|
|
199
|
+
"ready": ready,
|
|
200
|
+
"unmet_deps": unmet,
|
|
201
|
+
"forced": forced,
|
|
202
|
+
}
|
|
203
|
+
json.dump(output, sys.stdout, separators=(",", ":"))
|
|
204
|
+
sys.stdout.write("\n")
|
|
205
|
+
|
|
206
|
+
if not ready and not force:
|
|
207
|
+
return 1
|
|
208
|
+
return 0
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def main() -> int:
|
|
212
|
+
parser = argparse.ArgumentParser(
|
|
213
|
+
description="Campaign dependency computation and enforcement.",
|
|
214
|
+
)
|
|
215
|
+
mode = parser.add_mutually_exclusive_group(required=True)
|
|
216
|
+
mode.add_argument(
|
|
217
|
+
"--compute",
|
|
218
|
+
action="store_true",
|
|
219
|
+
help="Compute execution order via topological sort",
|
|
220
|
+
)
|
|
221
|
+
mode.add_argument(
|
|
222
|
+
"--check",
|
|
223
|
+
action="store_true",
|
|
224
|
+
help="Check if a skill's dependencies are satisfied",
|
|
225
|
+
)
|
|
226
|
+
parser.add_argument("--state-file", required=True, help="Path to _campaign-state.yaml")
|
|
227
|
+
parser.add_argument("--skill", help="Skill name (required for --check)")
|
|
228
|
+
parser.add_argument(
|
|
229
|
+
"--force",
|
|
230
|
+
action="store_true",
|
|
231
|
+
help="Force past dependency check (--check only)",
|
|
232
|
+
)
|
|
233
|
+
args = parser.parse_args()
|
|
234
|
+
|
|
235
|
+
if args.check and not args.skill:
|
|
236
|
+
parser.error("--skill is required when using --check")
|
|
237
|
+
|
|
238
|
+
if args.compute:
|
|
239
|
+
return compute(args.state_file)
|
|
240
|
+
return check(args.state_file, args.skill, force=args.force)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
if __name__ == "__main__":
|
|
244
|
+
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())
|
|
@@ -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())
|