@event4u/agent-config 3.0.0 → 3.1.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/.agent-src/commands/install-via-agent.md +129 -0
- package/.agent-src/commands/video/from-script.md +1 -1
- package/.agent-src/commands/video.md +1 -1
- package/.agent-src/contexts/execution/cheap-question-mechanics.md +81 -0
- package/.agent-src/rules/caveman-speak.md +2 -2
- package/.agent-src/rules/context-hygiene.md +36 -0
- package/.agent-src/rules/engineering-safety-floor.md +102 -0
- package/.agent-src/rules/finance-safety-floor.md +114 -0
- package/.agent-src/rules/git-history-discipline.md +1 -1
- package/.agent-src/rules/no-cheap-questions.md +34 -32
- package/.agent-src/rules/provider-lifecycle-discipline.md +4 -4
- package/.agent-src/rules/strategy-safety-floor.md +114 -0
- package/.agent-src/skills/agents-md-thin-root/SKILL.md +15 -9
- package/.agent-src/skills/async-python-patterns/SKILL.md +1 -1
- package/.agent-src/skills/project-analysis-node-express/SKILL.md +1 -1
- package/.agent-src/skills/readme-reviewer/SKILL.md +52 -3
- package/.agent-src/skills/readme-writing/SKILL.md +52 -4
- package/.agent-src/skills/readme-writing-package/SKILL.md +48 -5
- package/.agent-src/skills/systematic-debugging/SKILL.md +41 -0
- package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/.agent-src/templates/hooks/pre-commit-frontmatter +66 -0
- package/.agent-src/templates/hooks/pre-commit-roadmap-progress +78 -39
- package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +4 -1
- package/.agent-src/templates/scripts/work_engine/orchestration.py +25 -11
- package/.claude-plugin/marketplace.json +2 -1
- package/AGENTS.md +10 -8
- package/CHANGELOG.md +233 -123
- package/README.md +165 -553
- package/config/agent-settings.template.yml +0 -7
- package/config/discovery/packs.yml +20 -0
- package/config/discovery/unassigned-artefacts.yml +2 -0
- package/config/gitignore-block.txt +19 -3
- package/dist/cli/commands/uiServe.js +13 -4
- package/dist/cli/commands/uiServe.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +7 -0
- package/dist/discovery/discovery-manifest.json +2107 -1409
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +9 -9
- package/dist/discovery/orphan-report.md +10 -0
- package/dist/discovery/packs.json +1002 -0
- package/dist/discovery/trust-report.md +26 -0
- package/dist/discovery/workspaces.json +705 -0
- package/dist/mcp/registry-manifest.json +4 -4
- package/dist/router.json +1623 -0
- package/dist/server/app.js +11 -3
- package/dist/server/app.js.map +1 -1
- package/dist/server/io/atomicMultiWrite.js +3 -1
- package/dist/server/io/atomicMultiWrite.js.map +1 -1
- package/dist/server/io/yamlIO.js +22 -0
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/ping.js +8 -0
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/server/routes/schema.js +2 -2
- package/dist/server/routes/schema.js.map +1 -1
- package/dist/server/routes/settings.js +104 -23
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/server/routes/userMd.js +37 -27
- package/dist/server/routes/userMd.js.map +1 -1
- package/dist/server/routes/wizard.js +256 -20
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/server/schemas/settings.js +0 -1
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/server/token.js +10 -3
- package/dist/server/token.js.map +1 -1
- package/dist/server/writeRoot.js +28 -11
- package/dist/server/writeRoot.js.map +1 -1
- package/dist/server/writeRoot.test.js +22 -4
- package/dist/server/writeRoot.test.js.map +1 -1
- package/dist/shared/userMd/formAdapter.js +29 -51
- package/dist/shared/userMd/formAdapter.js.map +1 -1
- package/dist/shared/userMd/schema.js +32 -104
- package/dist/shared/userMd/schema.js.map +1 -1
- package/dist/shared/userMd/utils.js +64 -50
- package/dist/shared/userMd/utils.js.map +1 -1
- package/dist/ui/assets/index-D-DY1ywI.js +35 -0
- package/dist/ui/assets/index-D-DY1ywI.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/adrs/router/0001-three-tier-routing.md +5 -5
- package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +1 -1
- package/docs/architecture.md +3 -3
- package/docs/archive/CHANGELOG-pre-3.1.0.md +167 -0
- package/docs/catalog.md +30 -26
- package/docs/contracts/CHANGELOG-conventions.md +1 -1
- package/docs/contracts/agent-user-schema.md +6 -9
- package/docs/contracts/consumer-bridge.md +79 -0
- package/docs/contracts/discovery-manifest.md +209 -0
- package/docs/contracts/discovery-manifest.schema.json +77 -4
- package/docs/contracts/explain-trace.schema.json +1 -1
- package/docs/contracts/file-ownership-matrix.json +197 -13
- package/docs/contracts/frontmatter-contract.md +140 -0
- package/docs/contracts/gui-wizard.md +223 -0
- package/docs/contracts/installer-agent-mode.md +137 -0
- package/docs/contracts/kernel-membership.md +1 -1
- package/docs/contracts/mcp-tool-inventory.md +9 -9
- package/docs/contracts/namespace.md +6 -6
- package/docs/contracts/provider-lifecycle.md +5 -5
- package/docs/contracts/rule-router.md +4 -4
- package/docs/contracts/settings-api.md +53 -6
- package/docs/contracts/smoke-contracts.md +3 -3
- package/docs/contracts/trust-and-safety.md +144 -0
- package/docs/customization.md +2 -2
- package/docs/decisions/ADR-007-agent-discovery-scopes.md +12 -0
- package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +24 -0
- package/docs/decisions/ADR-015-discovery-manifest-contract.md +146 -0
- package/docs/decisions/ADR-016-installer-architecture.md +189 -0
- package/docs/decisions/ADR-017-monorepo-physical-layout.md +261 -0
- package/docs/decisions/ADR-018-trust-and-safety-layer.md +159 -0
- package/docs/decisions/ADR-019-router-json-dist-location.md +124 -0
- package/docs/decisions/ADR-020-global-only-consumer-scope.md +123 -0
- package/docs/decisions/ADR-021-deployment-shape.md +153 -0
- package/docs/decisions/INDEX.md +7 -0
- package/docs/deploy/connector-setup.md +129 -0
- package/docs/deploy/env-vars.md +70 -0
- package/docs/deploy/policy-cookbook.md +130 -0
- package/docs/deploy/quickstart.md +112 -0
- package/docs/distribution/public-install-smoke.md +68 -0
- package/docs/distribution/registries.md +55 -0
- package/docs/distribution/telemetry-privacy.md +128 -0
- package/docs/distribution/telemetry-schema.md +174 -0
- package/docs/featured-skills.md +95 -0
- package/docs/getting-started-by-role.md +19 -1
- package/docs/getting-started.md +2 -2
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +11 -8
- package/docs/guidelines/docs/readme-size-and-splitting.md +53 -1
- package/docs/installation.md +27 -14
- package/docs/maintainers/dev-mode.md +105 -0
- package/docs/setup/per-ide/claude-desktop.md +3 -2
- package/docs/wizard.md +39 -4
- package/package.json +18 -1
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_cli/cmd_doctor.py +150 -2
- package/scripts/_cli/cmd_explain.py +2 -1
- package/scripts/_cli/cmd_migrate_to_global.py +415 -0
- package/scripts/_cli/cmd_settings_migrate.py +146 -0
- package/scripts/_cli/explain_last/route.py +2 -1
- package/scripts/_dispatch.bash +36 -3
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/_lib/agent_settings.py +4 -1
- package/scripts/_lib/agent_src.py +157 -0
- package/scripts/agent-config +17 -6
- package/scripts/audit_skill_descriptions.py +18 -6
- package/scripts/build_discovery_manifest.py +373 -17
- package/scripts/check_artefact_checksums.py +104 -0
- package/scripts/check_cluster_patterns.py +20 -4
- package/scripts/check_command_count_messaging.py +33 -14
- package/scripts/check_council_references.py +43 -4
- package/scripts/check_overlay_cascade_subdirs.py +7 -3
- package/scripts/check_references.py +5 -2
- package/scripts/check_reply_consistency.py +32 -9
- package/scripts/check_template_pin_drift.py +24 -7
- package/scripts/check_token_optimizer_freshness.py +18 -3
- package/scripts/compile_router.py +34 -2
- package/scripts/compress.py +162 -44
- package/scripts/config/presets.py +19 -1
- package/scripts/config/profiles.py +16 -1
- package/scripts/discovery_stats.py +70 -0
- package/scripts/expected_perms.json +47 -0
- package/scripts/generate_index.py +78 -46
- package/scripts/generate_ownership_matrix.py +98 -43
- package/scripts/generate_pack_manifests.py +183 -0
- package/scripts/install +18 -1
- package/scripts/install.py +934 -59
- package/scripts/install.sh +27 -9
- package/scripts/lint_agents_layout.py +93 -13
- package/scripts/lint_agents_md.py +1 -1
- package/scripts/lint_archived_skills.py +32 -16
- package/scripts/lint_bench_corpus.py +14 -2
- package/scripts/lint_command_tiers.py +15 -2
- package/scripts/lint_featured_skills.py +139 -0
- package/scripts/lint_framework_leakage.py +33 -6
- package/scripts/lint_global_paths.py +147 -0
- package/scripts/lint_orchestration_dsl.py +6 -3
- package/scripts/lint_pack_boundaries.py +147 -0
- package/scripts/lint_pack_first_win.py +103 -0
- package/scripts/lint_readme_jargon.py +131 -0
- package/scripts/lint_readme_size.py +33 -0
- package/scripts/lint_rule_interactions.py +23 -5
- package/scripts/lint_rule_tiers.py +12 -3
- package/scripts/lint_trust_coherence.py +212 -0
- package/scripts/measure_rule_budget.py +22 -4
- package/scripts/move_artefact.py +143 -0
- package/scripts/new_skill.py +148 -0
- package/scripts/plan_physical_move.py +353 -0
- package/scripts/refine_ticket_detect.py +30 -7
- package/scripts/release.py +22 -2
- package/scripts/schemas/command.schema.json +4 -0
- package/scripts/skill_linter.py +248 -118
- package/scripts/skill_trigger_eval.py +28 -8
- package/scripts/smoke/kernel.sh +1 -1
- package/scripts/smoke/router.sh +24 -5
- package/scripts/smoke/skills.sh +15 -7
- package/scripts/smoke_quickstart.py +11 -2
- package/scripts/snapshot_agent_outputs.py +144 -0
- package/scripts/update_counts.py +45 -17
- package/scripts/validate_decision_engine.py +9 -1
- package/scripts/validate_discovery_manifest.py +94 -0
- package/scripts/validate_frontmatter.py +39 -20
- package/scripts/verify_physical_move.py +185 -0
- package/templates/agent-user.md +0 -1
- package/templates/agent-user.yml +21 -0
- package/templates/minimal/agents-overrides-readme.md +46 -0
- package/templates/minimal/overrides-gitkeep +2 -0
- package/dist/ui/assets/index-BTRcKDlB.js +0 -39
- package/dist/ui/assets/index-BTRcKDlB.js.map +0 -1
- package/templates/minimal/agents-gitkeep +0 -2
|
@@ -25,18 +25,27 @@ from typing import Any, Iterable
|
|
|
25
25
|
import yaml
|
|
26
26
|
|
|
27
27
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
28
|
-
from validate_frontmatter import parse_frontmatter # noqa: E402
|
|
28
|
+
from validate_frontmatter import _FRONTMATTER_RE, parse_frontmatter # noqa: E402
|
|
29
|
+
from _lib.agent_src import artefact_roots, logical_relpath, resolve_logical, strip_source_prefix # noqa: E402
|
|
29
30
|
|
|
30
31
|
ROOT = Path(__file__).resolve().parents[1]
|
|
31
32
|
SRC = ROOT / ".agent-src.uncompressed"
|
|
32
33
|
VOCAB_DIR = ROOT / "config" / "discovery"
|
|
33
34
|
DEFAULT_OUT = ROOT / "dist" / "discovery" / "discovery-manifest.json"
|
|
34
35
|
DEFAULT_SUMMARY = ROOT / "dist" / "discovery" / "discovery-manifest.summary.md"
|
|
35
|
-
|
|
36
|
+
DEFAULT_DEPRECATION_REPORT = ROOT / "dist" / "discovery" / "deprecation-report.md"
|
|
37
|
+
DEFAULT_TRUST_REPORT = ROOT / "dist" / "discovery" / "trust-report.md"
|
|
38
|
+
DEFAULT_ORPHAN_REPORT = ROOT / "dist" / "discovery" / "orphan-report.md"
|
|
39
|
+
DEFAULT_WORKSPACES_JSON = ROOT / "dist" / "discovery" / "workspaces.json"
|
|
40
|
+
DEFAULT_PACKS_JSON = ROOT / "dist" / "discovery" / "packs.json"
|
|
41
|
+
TRUST_ROOTS = (".agent-src.uncompressed", ".augment", ".claude", ".agent-src", "packages")
|
|
36
42
|
|
|
37
43
|
_FM_KEYS = ("workspaces", "packs", "lifecycle", "trust", "install")
|
|
38
44
|
_TRUST_REQ = ("level", "confidence", "human_review_required")
|
|
39
45
|
_INSTALL_REQ = ("default", "removable")
|
|
46
|
+
_LIFECYCLE_VALUES = ("active", "experimental", "deprecated", "archived")
|
|
47
|
+
_TRUST_VALUES = ("core", "professional", "experimental", "advisory", "restricted")
|
|
48
|
+
_CATEGORY_VALUES = ("skill", "rule", "command", "template")
|
|
40
49
|
|
|
41
50
|
|
|
42
51
|
def _load_yaml(path: Path) -> Any:
|
|
@@ -44,10 +53,34 @@ def _load_yaml(path: Path) -> Any:
|
|
|
44
53
|
|
|
45
54
|
|
|
46
55
|
def _vocab() -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, str]]:
|
|
56
|
+
"""Load discovery vocab. ``overrides`` keys are normalised to the
|
|
57
|
+
*current* physical repo-relative path, regardless of whether the YAML
|
|
58
|
+
lists the legacy ``.agent-src.uncompressed/...`` prefix or a
|
|
59
|
+
``packages/*/.agent-src.uncompressed/...`` prefix. The lookup site
|
|
60
|
+
(``_build``) compares against physical paths emitted by
|
|
61
|
+
``_iter_artefacts``.
|
|
62
|
+
"""
|
|
47
63
|
workspaces = _load_yaml(VOCAB_DIR / "workspaces.yml") or []
|
|
48
64
|
packs = _load_yaml(VOCAB_DIR / "packs.yml") or []
|
|
49
65
|
raw_un = _load_yaml(VOCAB_DIR / "unassigned-artefacts.yml") or []
|
|
50
|
-
overrides
|
|
66
|
+
overrides: dict[str, str] = {}
|
|
67
|
+
for entry in raw_un or []:
|
|
68
|
+
raw_path = entry["path"]
|
|
69
|
+
reason = entry["reason"]
|
|
70
|
+
logical = strip_source_prefix(raw_path)
|
|
71
|
+
if logical is None:
|
|
72
|
+
# Path isn't under any source root — keep as-is (e.g. docs/).
|
|
73
|
+
overrides[raw_path] = reason
|
|
74
|
+
continue
|
|
75
|
+
# Map logical → current physical, so the lookup matches whatever
|
|
76
|
+
# root the file actually lives in post-move.
|
|
77
|
+
physical = resolve_logical(logical)
|
|
78
|
+
if physical is not None:
|
|
79
|
+
overrides[physical.relative_to(ROOT).as_posix()] = reason
|
|
80
|
+
else:
|
|
81
|
+
# Not yet present — keep both the raw and the logical key so
|
|
82
|
+
# the manifest stays stable when the file later lands.
|
|
83
|
+
overrides[raw_path] = reason
|
|
51
84
|
return workspaces, packs, overrides
|
|
52
85
|
|
|
53
86
|
|
|
@@ -56,17 +89,56 @@ def _scanner_version() -> str:
|
|
|
56
89
|
return h[:12]
|
|
57
90
|
|
|
58
91
|
|
|
92
|
+
def _artefact_checksum(path: Path, fm: dict[str, Any] | None) -> str:
|
|
93
|
+
"""sha256 over normalized artefact content (ADR-015).
|
|
94
|
+
|
|
95
|
+
Normalization: frontmatter re-serialized as compact JSON with sorted
|
|
96
|
+
keys, body stripped of trailing whitespace per line + single trailing
|
|
97
|
+
newline. Drops cosmetic-only diffs (key reorder, blank-line trim)
|
|
98
|
+
so the installer's drift check survives reformatting.
|
|
99
|
+
"""
|
|
100
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
101
|
+
match = _FRONTMATTER_RE.search(text)
|
|
102
|
+
if fm is None or match is None:
|
|
103
|
+
body = "\n".join(line.rstrip() for line in text.splitlines()).rstrip() + "\n"
|
|
104
|
+
raw = body.encode("utf-8")
|
|
105
|
+
else:
|
|
106
|
+
fm_json = json.dumps(fm, sort_keys=True, ensure_ascii=False, separators=(",", ":"))
|
|
107
|
+
body_text = text[match.end():]
|
|
108
|
+
body = "\n".join(line.rstrip() for line in body_text.splitlines()).rstrip() + "\n"
|
|
109
|
+
raw = (fm_json + "\n" + body).encode("utf-8")
|
|
110
|
+
return "sha256:" + hashlib.sha256(raw).hexdigest()
|
|
111
|
+
|
|
112
|
+
|
|
59
113
|
def _iter_artefacts() -> Iterable[tuple[Path, str]]:
|
|
60
|
-
"""Deterministic order: skills → rules → commands → templates.
|
|
61
|
-
|
|
114
|
+
"""Deterministic order: skills → rules → commands → templates.
|
|
115
|
+
|
|
116
|
+
Walks every source root (legacy ``.agent-src.uncompressed/`` plus any
|
|
117
|
+
``packages/*/.agent-src.uncompressed/``) so the manifest survives the
|
|
118
|
+
physical move (ADR-017). Within each category, paths are sorted by
|
|
119
|
+
their *logical* identity to keep ordering stable across moves.
|
|
120
|
+
"""
|
|
121
|
+
def _collect(subdir: str, pattern: str) -> list[Path]:
|
|
122
|
+
seen: dict[str, Path] = {}
|
|
123
|
+
for root in artefact_roots():
|
|
124
|
+
base = root / subdir
|
|
125
|
+
if not base.exists():
|
|
126
|
+
continue
|
|
127
|
+
for p in base.rglob(pattern):
|
|
128
|
+
if not p.is_file():
|
|
129
|
+
continue
|
|
130
|
+
rel = p.relative_to(root).as_posix()
|
|
131
|
+
seen.setdefault(rel, p)
|
|
132
|
+
return [seen[k] for k in sorted(seen)]
|
|
133
|
+
|
|
134
|
+
for p in _collect("skills", "SKILL.md"):
|
|
62
135
|
yield p, "skill"
|
|
63
|
-
for p in
|
|
136
|
+
for p in _collect("rules", "*.md"):
|
|
64
137
|
yield p, "rule"
|
|
65
|
-
for p in
|
|
138
|
+
for p in _collect("commands", "*.md"):
|
|
66
139
|
yield p, "command"
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
yield p, "template"
|
|
140
|
+
for p in _collect("templates", "*.md"):
|
|
141
|
+
yield p, "template"
|
|
70
142
|
|
|
71
143
|
|
|
72
144
|
def _trusted(path: Path) -> bool:
|
|
@@ -109,13 +181,13 @@ def _classify(
|
|
|
109
181
|
return None, f"unknown pack(s): {', '.join(bad)} (not in vocabulary)"
|
|
110
182
|
|
|
111
183
|
lc = fm["lifecycle"]
|
|
112
|
-
if lc not in
|
|
184
|
+
if lc not in _LIFECYCLE_VALUES:
|
|
113
185
|
return None, f"lifecycle: invalid value '{lc}'"
|
|
114
186
|
|
|
115
187
|
trust = fm["trust"]
|
|
116
188
|
if not isinstance(trust, dict) or any(k not in trust for k in _TRUST_REQ):
|
|
117
189
|
return None, f"trust: missing required key(s) {_TRUST_REQ}"
|
|
118
|
-
if trust["level"] not in
|
|
190
|
+
if trust["level"] not in _TRUST_VALUES:
|
|
119
191
|
return None, f"trust.level: invalid '{trust['level']}'"
|
|
120
192
|
if trust["confidence"] not in ("high", "medium", "low"):
|
|
121
193
|
return None, f"trust.confidence: invalid '{trust['confidence']}'"
|
|
@@ -128,7 +200,18 @@ def _classify(
|
|
|
128
200
|
if not isinstance(install["default"], bool) or not isinstance(install["removable"], bool):
|
|
129
201
|
return None, "install.default and install.removable must be boolean"
|
|
130
202
|
|
|
131
|
-
|
|
203
|
+
# Optional `requires` — ADR-015 dependency edges. Closed vocabulary.
|
|
204
|
+
requires_raw = fm.get("requires")
|
|
205
|
+
requires: list[str] = []
|
|
206
|
+
if requires_raw is not None:
|
|
207
|
+
if not isinstance(requires_raw, list):
|
|
208
|
+
return None, "requires: must be a list of pack ids"
|
|
209
|
+
bad = [r for r in requires_raw if r not in pack_ids]
|
|
210
|
+
if bad:
|
|
211
|
+
return None, f"requires: unknown pack(s) {', '.join(bad)}"
|
|
212
|
+
requires = list(requires_raw)
|
|
213
|
+
|
|
214
|
+
payload: dict[str, Any] = {
|
|
132
215
|
"workspaces": list(ws),
|
|
133
216
|
"packs": list(pk),
|
|
134
217
|
"lifecycle": lc,
|
|
@@ -138,7 +221,10 @@ def _classify(
|
|
|
138
221
|
"human_review_required": trust["human_review_required"],
|
|
139
222
|
},
|
|
140
223
|
"install": {"default": install["default"], "removable": install["removable"]},
|
|
141
|
-
}
|
|
224
|
+
}
|
|
225
|
+
if requires:
|
|
226
|
+
payload["requires"] = requires
|
|
227
|
+
return payload, None
|
|
142
228
|
|
|
143
229
|
|
|
144
230
|
|
|
@@ -150,6 +236,11 @@ def _build(strict: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
|
150
236
|
artefacts: list[dict[str, Any]] = []
|
|
151
237
|
unassigned: list[dict[str, Any]] = []
|
|
152
238
|
pack_counts: dict[str, int] = {pid: 0 for pid in pack_ids}
|
|
239
|
+
# Phase 5.1 (ADR-018): per-pack trust mix + HRR count for installer.
|
|
240
|
+
pack_trust_counts: dict[str, dict[str, int]] = {
|
|
241
|
+
pid: {lvl: 0 for lvl in _TRUST_VALUES} for pid in pack_ids
|
|
242
|
+
}
|
|
243
|
+
pack_hrr_counts: dict[str, int] = {pid: 0 for pid in pack_ids}
|
|
153
244
|
|
|
154
245
|
documented_unassigned: list[dict[str, Any]] = []
|
|
155
246
|
|
|
@@ -171,9 +262,16 @@ def _build(strict: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
|
171
262
|
if isinstance(name, str) and name:
|
|
172
263
|
entry["name"] = name
|
|
173
264
|
entry.update(payload or {})
|
|
265
|
+
entry["checksum"] = _artefact_checksum(path, fm)
|
|
174
266
|
artefacts.append(entry)
|
|
267
|
+
trust_level = (payload.get("trust") or {}).get("level") if payload else None
|
|
268
|
+
hrr = bool((payload.get("trust") or {}).get("human_review_required")) if payload else False
|
|
175
269
|
for pid in payload["packs"] if payload else []:
|
|
176
270
|
pack_counts[pid] = pack_counts.get(pid, 0) + 1
|
|
271
|
+
if trust_level in pack_trust_counts.get(pid, {}):
|
|
272
|
+
pack_trust_counts[pid][trust_level] += 1
|
|
273
|
+
if hrr:
|
|
274
|
+
pack_hrr_counts[pid] = pack_hrr_counts.get(pid, 0) + 1
|
|
177
275
|
|
|
178
276
|
artefacts.sort(key=lambda e: e["path"])
|
|
179
277
|
unassigned.sort(key=lambda e: e["path"])
|
|
@@ -191,13 +289,16 @@ def _build(strict: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
|
191
289
|
]
|
|
192
290
|
pk_out = []
|
|
193
291
|
for p in packs:
|
|
292
|
+
pid = p["id"]
|
|
194
293
|
item = {
|
|
195
|
-
"id":
|
|
294
|
+
"id": pid,
|
|
196
295
|
"label": p["label"],
|
|
197
296
|
"description": p["description"],
|
|
198
297
|
"workspaces": list(p.get("workspaces") or []),
|
|
199
298
|
"trust_level_default": p["trust_level_default"],
|
|
200
|
-
"artefact_count": pack_counts.get(
|
|
299
|
+
"artefact_count": pack_counts.get(pid, 0),
|
|
300
|
+
"trust_summary": dict(pack_trust_counts.get(pid, {lvl: 0 for lvl in _TRUST_VALUES})),
|
|
301
|
+
"human_review_required": pack_hrr_counts.get(pid, 0),
|
|
201
302
|
}
|
|
202
303
|
if p.get("requires_hint"):
|
|
203
304
|
item["requires_hint"] = list(p["requires_hint"])
|
|
@@ -209,6 +310,8 @@ def _build(strict: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
|
209
310
|
f"first: {unassigned[0]['path']} — {unassigned[0]['reason']}"
|
|
210
311
|
)
|
|
211
312
|
|
|
313
|
+
stats = _compute_stats(artefacts, unassigned, documented_unassigned)
|
|
314
|
+
|
|
212
315
|
manifest = {
|
|
213
316
|
"version": 1,
|
|
214
317
|
"generated_at": _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
@@ -219,10 +322,40 @@ def _build(strict: bool) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
|
219
322
|
"artefacts": artefacts,
|
|
220
323
|
"unassigned": unassigned,
|
|
221
324
|
"documented_unassigned": documented_unassigned,
|
|
325
|
+
"stats": stats,
|
|
222
326
|
}
|
|
223
327
|
return manifest, unassigned
|
|
224
328
|
|
|
225
329
|
|
|
330
|
+
def _compute_stats(
|
|
331
|
+
artefacts: list[dict[str, Any]],
|
|
332
|
+
unassigned: list[dict[str, Any]],
|
|
333
|
+
documented_unassigned: list[dict[str, Any]],
|
|
334
|
+
) -> dict[str, Any]:
|
|
335
|
+
"""Aggregate counts derived from the artefact list (ADR-015)."""
|
|
336
|
+
by_category = {k: 0 for k in _CATEGORY_VALUES}
|
|
337
|
+
by_lifecycle = {k: 0 for k in _LIFECYCLE_VALUES}
|
|
338
|
+
by_trust_level = {k: 0 for k in _TRUST_VALUES}
|
|
339
|
+
for a in artefacts:
|
|
340
|
+
cat = a.get("category")
|
|
341
|
+
if cat in by_category:
|
|
342
|
+
by_category[cat] += 1
|
|
343
|
+
lc = a.get("lifecycle")
|
|
344
|
+
if lc in by_lifecycle:
|
|
345
|
+
by_lifecycle[lc] += 1
|
|
346
|
+
lvl = a.get("trust", {}).get("level")
|
|
347
|
+
if lvl in by_trust_level:
|
|
348
|
+
by_trust_level[lvl] += 1
|
|
349
|
+
return {
|
|
350
|
+
"total_artefacts": len(artefacts),
|
|
351
|
+
"by_category": by_category,
|
|
352
|
+
"by_lifecycle": by_lifecycle,
|
|
353
|
+
"by_trust_level": by_trust_level,
|
|
354
|
+
"unassigned_count": len(unassigned),
|
|
355
|
+
"documented_unassigned_count": len(documented_unassigned),
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
226
359
|
def _serialize(manifest: dict[str, Any]) -> str:
|
|
227
360
|
"""Deterministic JSON: sorted keys, 2-space indent, trailing newline."""
|
|
228
361
|
return json.dumps(manifest, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
@@ -240,6 +373,194 @@ def _finalise_checksum(manifest: dict[str, Any]) -> None:
|
|
|
240
373
|
manifest["checksum"] = f"sha256:{digest}"
|
|
241
374
|
|
|
242
375
|
|
|
376
|
+
def _deprecation_report(manifest: dict[str, Any]) -> str:
|
|
377
|
+
"""List every ``lifecycle: deprecated`` artefact (ADR-015, Phase 4)."""
|
|
378
|
+
items = [a for a in manifest["artefacts"] if a.get("lifecycle") == "deprecated"]
|
|
379
|
+
items.sort(key=lambda a: a["path"])
|
|
380
|
+
lines = ["# Discovery — Deprecation Report", ""]
|
|
381
|
+
lines.append(f"- Generated: `{manifest['generated_at']}`")
|
|
382
|
+
lines.append(f"- Deprecated artefacts: **{len(items)}**")
|
|
383
|
+
lines.append("")
|
|
384
|
+
if not items:
|
|
385
|
+
lines.append("_None. Tree is clean._")
|
|
386
|
+
lines.append("")
|
|
387
|
+
return "\n".join(lines) + "\n"
|
|
388
|
+
lines.append("| Path | Category | Trust |")
|
|
389
|
+
lines.append("|---|---|---|")
|
|
390
|
+
for a in items:
|
|
391
|
+
lines.append(f"| `{a['path']}` | {a['category']} | {a['trust']['level']} |")
|
|
392
|
+
lines.append("")
|
|
393
|
+
return "\n".join(lines) + "\n"
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _trust_report(manifest: dict[str, Any]) -> str:
|
|
397
|
+
"""Trust-level breakdown by workspace + human-review sanity flag."""
|
|
398
|
+
by_ws: dict[str, dict[str, int]] = {}
|
|
399
|
+
review_flags: list[dict[str, Any]] = []
|
|
400
|
+
for a in manifest["artefacts"]:
|
|
401
|
+
level = a["trust"]["level"]
|
|
402
|
+
for ws in a["workspaces"]:
|
|
403
|
+
by_ws.setdefault(ws, {k: 0 for k in _TRUST_VALUES})[level] += 1
|
|
404
|
+
if a["trust"].get("human_review_required"):
|
|
405
|
+
review_flags.append(a)
|
|
406
|
+
review_flags.sort(key=lambda a: a["path"])
|
|
407
|
+
lines = ["# Discovery — Trust Report", ""]
|
|
408
|
+
lines.append(f"- Generated: `{manifest['generated_at']}`")
|
|
409
|
+
lines.append(f"- Workspaces tracked: **{len(by_ws)}**")
|
|
410
|
+
lines.append(f"- Human-review-required artefacts: **{len(review_flags)}**")
|
|
411
|
+
lines.append("")
|
|
412
|
+
lines.append("## Trust levels by workspace")
|
|
413
|
+
lines.append("")
|
|
414
|
+
header = "| Workspace | " + " | ".join(_TRUST_VALUES) + " |"
|
|
415
|
+
sep = "|---|" + "|".join(["---"] * len(_TRUST_VALUES)) + "|"
|
|
416
|
+
lines.extend([header, sep])
|
|
417
|
+
for ws in sorted(by_ws):
|
|
418
|
+
counts = by_ws[ws]
|
|
419
|
+
row = f"| `{ws}` | " + " | ".join(str(counts[k]) for k in _TRUST_VALUES) + " |"
|
|
420
|
+
lines.append(row)
|
|
421
|
+
lines.append("")
|
|
422
|
+
if review_flags:
|
|
423
|
+
lines.append("## Human-review-required artefacts")
|
|
424
|
+
lines.append("")
|
|
425
|
+
lines.append("| Path | Workspaces | Trust |")
|
|
426
|
+
lines.append("|---|---|---|")
|
|
427
|
+
for a in review_flags:
|
|
428
|
+
lines.append(
|
|
429
|
+
f"| `{a['path']}` | {', '.join(a['workspaces'])} | {a['trust']['level']} |"
|
|
430
|
+
)
|
|
431
|
+
lines.append("")
|
|
432
|
+
return "\n".join(lines) + "\n"
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _orphan_artefacts(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
436
|
+
"""Artefacts whose declared pack has no other members (likely typo).
|
|
437
|
+
|
|
438
|
+
``experimental`` lifecycle is a sanctioned carve-out (ADR-015).
|
|
439
|
+
"""
|
|
440
|
+
pack_members: dict[str, list[dict[str, Any]]] = {}
|
|
441
|
+
for a in manifest["artefacts"]:
|
|
442
|
+
for pid in a["packs"]:
|
|
443
|
+
pack_members.setdefault(pid, []).append(a)
|
|
444
|
+
orphans: list[dict[str, Any]] = []
|
|
445
|
+
for a in manifest["artefacts"]:
|
|
446
|
+
if a.get("lifecycle") == "experimental":
|
|
447
|
+
continue
|
|
448
|
+
for pid in a["packs"]:
|
|
449
|
+
if len(pack_members.get(pid, [])) == 1:
|
|
450
|
+
orphans.append({"path": a["path"], "pack": pid, "category": a["category"]})
|
|
451
|
+
break
|
|
452
|
+
orphans.sort(key=lambda o: o["path"])
|
|
453
|
+
return orphans
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _orphan_report(manifest: dict[str, Any]) -> str:
|
|
457
|
+
orphans = _orphan_artefacts(manifest)
|
|
458
|
+
lines = ["# Discovery — Orphan Report", ""]
|
|
459
|
+
lines.append(f"- Generated: `{manifest['generated_at']}`")
|
|
460
|
+
lines.append(f"- Orphan artefacts: **{len(orphans)}**")
|
|
461
|
+
lines.append("")
|
|
462
|
+
lines.append(
|
|
463
|
+
"> An orphan is an artefact whose declared pack has no other members."
|
|
464
|
+
)
|
|
465
|
+
lines.append("> `lifecycle: experimental` is a sanctioned carve-out (ADR-015).")
|
|
466
|
+
lines.append("")
|
|
467
|
+
if not orphans:
|
|
468
|
+
lines.append("_No orphans. Pack assignments look healthy._")
|
|
469
|
+
lines.append("")
|
|
470
|
+
return "\n".join(lines) + "\n"
|
|
471
|
+
lines.append("| Path | Pack | Category |")
|
|
472
|
+
lines.append("|---|---|---|")
|
|
473
|
+
for o in orphans:
|
|
474
|
+
lines.append(f"| `{o['path']}` | `{o['pack']}` | {o['category']} |")
|
|
475
|
+
lines.append("")
|
|
476
|
+
return "\n".join(lines) + "\n"
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _workspaces_view(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
480
|
+
"""Flattened workspace sub-view (ADR-015 Phase 5).
|
|
481
|
+
|
|
482
|
+
For each workspace: artefact count + per-pack artefact ids. Cheap
|
|
483
|
+
surface for the browser wizard (and any other lightweight consumer)
|
|
484
|
+
so they don't need to walk the full manifest.
|
|
485
|
+
"""
|
|
486
|
+
pack_to_artefacts: dict[str, list[str]] = {}
|
|
487
|
+
for a in manifest["artefacts"]:
|
|
488
|
+
for pid in a["packs"]:
|
|
489
|
+
pack_to_artefacts.setdefault(pid, []).append(a["path"])
|
|
490
|
+
for pid in pack_to_artefacts:
|
|
491
|
+
pack_to_artefacts[pid].sort()
|
|
492
|
+
workspaces: list[dict[str, Any]] = []
|
|
493
|
+
for w in manifest["workspaces"]:
|
|
494
|
+
packs_block: list[dict[str, Any]] = []
|
|
495
|
+
for pid in list(w.get("default_packs", [])) + list(w.get("optional_packs", [])):
|
|
496
|
+
ids = pack_to_artefacts.get(pid, [])
|
|
497
|
+
packs_block.append({"id": pid, "artefact_count": len(ids), "artefacts": ids})
|
|
498
|
+
# Artefacts visible in this workspace (union across its packs)
|
|
499
|
+
visible: set[str] = set()
|
|
500
|
+
for entry in packs_block:
|
|
501
|
+
visible.update(entry["artefacts"])
|
|
502
|
+
workspaces.append(
|
|
503
|
+
{
|
|
504
|
+
"id": w["id"],
|
|
505
|
+
"label": w["label"],
|
|
506
|
+
"description": w["description"],
|
|
507
|
+
"default_packs": list(w.get("default_packs", [])),
|
|
508
|
+
"optional_packs": list(w.get("optional_packs", [])),
|
|
509
|
+
"artefact_count": len(visible),
|
|
510
|
+
"packs": packs_block,
|
|
511
|
+
}
|
|
512
|
+
)
|
|
513
|
+
return {
|
|
514
|
+
"generated_at": manifest["generated_at"],
|
|
515
|
+
"scanner_version": manifest["scanner_version"],
|
|
516
|
+
"checksum": manifest["checksum"],
|
|
517
|
+
"workspaces": workspaces,
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _packs_view(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
522
|
+
"""Flattened pack sub-view (ADR-015 Phase 5).
|
|
523
|
+
|
|
524
|
+
Per-pack: artefact ids, lifecycle counts, trust counts. Lightweight
|
|
525
|
+
payload for a pack-picker UI.
|
|
526
|
+
"""
|
|
527
|
+
pack_to_artefacts: dict[str, list[dict[str, Any]]] = {}
|
|
528
|
+
for a in manifest["artefacts"]:
|
|
529
|
+
for pid in a["packs"]:
|
|
530
|
+
pack_to_artefacts.setdefault(pid, []).append(a)
|
|
531
|
+
packs: list[dict[str, Any]] = []
|
|
532
|
+
for p in manifest["packs"]:
|
|
533
|
+
members = pack_to_artefacts.get(p["id"], [])
|
|
534
|
+
lifecycle_counts = {k: 0 for k in _LIFECYCLE_VALUES}
|
|
535
|
+
trust_counts = {k: 0 for k in _TRUST_VALUES}
|
|
536
|
+
ids: list[str] = []
|
|
537
|
+
for a in members:
|
|
538
|
+
ids.append(a["path"])
|
|
539
|
+
lifecycle_counts[a["lifecycle"]] += 1
|
|
540
|
+
trust_counts[a["trust"]["level"]] += 1
|
|
541
|
+
ids.sort()
|
|
542
|
+
packs.append(
|
|
543
|
+
{
|
|
544
|
+
"id": p["id"],
|
|
545
|
+
"label": p["label"],
|
|
546
|
+
"description": p["description"],
|
|
547
|
+
"workspaces": list(p.get("workspaces", [])),
|
|
548
|
+
"requires_hint": list(p.get("requires_hint", [])),
|
|
549
|
+
"trust_level_default": p.get("trust_level_default"),
|
|
550
|
+
"artefact_count": len(ids),
|
|
551
|
+
"artefacts": ids,
|
|
552
|
+
"by_lifecycle": lifecycle_counts,
|
|
553
|
+
"by_trust_level": trust_counts,
|
|
554
|
+
}
|
|
555
|
+
)
|
|
556
|
+
return {
|
|
557
|
+
"generated_at": manifest["generated_at"],
|
|
558
|
+
"scanner_version": manifest["scanner_version"],
|
|
559
|
+
"checksum": manifest["checksum"],
|
|
560
|
+
"packs": packs,
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
|
|
243
564
|
def _summary(manifest: dict[str, Any]) -> str:
|
|
244
565
|
lines = ["# Discovery Manifest — Summary", ""]
|
|
245
566
|
lines.append(f"- Generated: `{manifest['generated_at']}`")
|
|
@@ -268,6 +589,11 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
268
589
|
parser.add_argument("--write", action="store_true")
|
|
269
590
|
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
|
|
270
591
|
parser.add_argument("--summary", type=Path, default=DEFAULT_SUMMARY)
|
|
592
|
+
parser.add_argument("--deprecation-report", type=Path, default=DEFAULT_DEPRECATION_REPORT)
|
|
593
|
+
parser.add_argument("--trust-report", type=Path, default=DEFAULT_TRUST_REPORT)
|
|
594
|
+
parser.add_argument("--orphan-report", type=Path, default=DEFAULT_ORPHAN_REPORT)
|
|
595
|
+
parser.add_argument("--workspaces-json", type=Path, default=DEFAULT_WORKSPACES_JSON)
|
|
596
|
+
parser.add_argument("--packs-json", type=Path, default=DEFAULT_PACKS_JSON)
|
|
271
597
|
parser.add_argument("--strict", action="store_true")
|
|
272
598
|
parser.add_argument("--quiet", action="store_true")
|
|
273
599
|
args = parser.parse_args(argv)
|
|
@@ -279,10 +605,39 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
279
605
|
_finalise_checksum(manifest)
|
|
280
606
|
body = _serialize(manifest)
|
|
281
607
|
|
|
608
|
+
# ADR-015 Phase 4: orphan gate. Non-experimental artefacts whose declared
|
|
609
|
+
# pack has no other members are a typo signal. Strict (CI) mode fails;
|
|
610
|
+
# local runs only warn.
|
|
611
|
+
orphans = _orphan_artefacts(manifest)
|
|
612
|
+
if orphans and strict:
|
|
613
|
+
print(
|
|
614
|
+
f"error: {len(orphans)} orphan artefact(s) found "
|
|
615
|
+
"(non-experimental, pack has no other members). "
|
|
616
|
+
"See dist/discovery/orphan-report.md.",
|
|
617
|
+
file=sys.stderr,
|
|
618
|
+
)
|
|
619
|
+
for o in orphans[:10]:
|
|
620
|
+
print(f" - {o['path']} (pack '{o['pack']}')", file=sys.stderr)
|
|
621
|
+
return 1
|
|
622
|
+
|
|
282
623
|
if args.write:
|
|
283
624
|
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
284
625
|
args.out.write_text(body, encoding="utf-8")
|
|
285
626
|
args.summary.write_text(_summary(manifest), encoding="utf-8")
|
|
627
|
+
args.deprecation_report.write_text(_deprecation_report(manifest), encoding="utf-8")
|
|
628
|
+
args.trust_report.write_text(_trust_report(manifest), encoding="utf-8")
|
|
629
|
+
args.orphan_report.write_text(_orphan_report(manifest), encoding="utf-8")
|
|
630
|
+
# Phase 5 sub-views — flattened workspace/pack JSON for
|
|
631
|
+
# lightweight consumers (browser wizard) so they don't need to
|
|
632
|
+
# walk the full manifest.
|
|
633
|
+
args.workspaces_json.write_text(
|
|
634
|
+
json.dumps(_workspaces_view(manifest), indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
|
635
|
+
encoding="utf-8",
|
|
636
|
+
)
|
|
637
|
+
args.packs_json.write_text(
|
|
638
|
+
json.dumps(_packs_view(manifest), indent=2, sort_keys=True, ensure_ascii=False) + "\n",
|
|
639
|
+
encoding="utf-8",
|
|
640
|
+
)
|
|
286
641
|
# Sidecar SHA-256 of the on-disk manifest bytes for tamper detection
|
|
287
642
|
# by downstream consumers (security-engineer council fold-in, R3 Phase 7).
|
|
288
643
|
sidecar = args.out.with_suffix(args.out.suffix + ".sha256")
|
|
@@ -291,7 +646,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
291
646
|
if not args.quiet:
|
|
292
647
|
print(
|
|
293
648
|
f"wrote {args.out.relative_to(ROOT)} "
|
|
294
|
-
f"({len(manifest['artefacts'])} artefacts, {len(unassigned)} unassigned
|
|
649
|
+
f"({len(manifest['artefacts'])} artefacts, {len(unassigned)} unassigned, "
|
|
650
|
+
f"{len(orphans)} orphans)"
|
|
295
651
|
)
|
|
296
652
|
else:
|
|
297
653
|
sys.stdout.write(body)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Phase-6 checksum-stability gate (monorepo Phase 2, ADR-015).
|
|
3
|
+
|
|
4
|
+
For every artefact in the committed
|
|
5
|
+
``dist/discovery/discovery-manifest.json``, recompute the per-artefact
|
|
6
|
+
sha256 using the same normalization as
|
|
7
|
+
``scripts/build_discovery_manifest.py::_artefact_checksum`` and assert
|
|
8
|
+
it matches the manifest entry.
|
|
9
|
+
|
|
10
|
+
Distinct from ``validate-discovery-manifest`` (which rebuilds the
|
|
11
|
+
whole manifest in memory and diffs): this gate is the focused
|
|
12
|
+
"does the committed checksum still match the source bytes?" check
|
|
13
|
+
that third-party consumers can run to verify the manifest contract.
|
|
14
|
+
|
|
15
|
+
CLI:
|
|
16
|
+
python scripts/check_artefact_checksums.py [--manifest PATH] [--quiet]
|
|
17
|
+
|
|
18
|
+
Exit codes:
|
|
19
|
+
0 every artefact checksum matches its source bytes
|
|
20
|
+
1 one or more checksums drifted (manifest is stale, or source moved)
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
30
|
+
DEFAULT_MANIFEST = ROOT / "dist" / "discovery" / "discovery-manifest.json"
|
|
31
|
+
|
|
32
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
33
|
+
# Import the same hashing primitive the builder uses so normalisation
|
|
34
|
+
# stays in lockstep with the generator. (ADR-015 §Phase 6.)
|
|
35
|
+
from build_discovery_manifest import _artefact_checksum # noqa: E402
|
|
36
|
+
from validate_frontmatter import parse_frontmatter # noqa: E402
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _frontmatter(path: Path) -> dict | None:
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return None
|
|
42
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
43
|
+
fm, _ = parse_frontmatter(text)
|
|
44
|
+
return fm
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _check(manifest_path: Path) -> tuple[int, list[str]]:
|
|
48
|
+
if not manifest_path.exists():
|
|
49
|
+
return 1, [f"manifest not found at {manifest_path}"]
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
53
|
+
except json.JSONDecodeError as exc:
|
|
54
|
+
return 1, [f"invalid JSON: {exc}"]
|
|
55
|
+
|
|
56
|
+
errors: list[str] = []
|
|
57
|
+
for art in manifest.get("artefacts", []):
|
|
58
|
+
rel = art.get("path")
|
|
59
|
+
recorded = art.get("checksum")
|
|
60
|
+
if not isinstance(rel, str) or not isinstance(recorded, str):
|
|
61
|
+
errors.append(f"malformed entry: {art!r}")
|
|
62
|
+
continue
|
|
63
|
+
src = ROOT / rel
|
|
64
|
+
if not src.exists():
|
|
65
|
+
errors.append(f"{rel}: source file missing")
|
|
66
|
+
continue
|
|
67
|
+
actual = _artefact_checksum(src, _frontmatter(src))
|
|
68
|
+
if actual != recorded:
|
|
69
|
+
errors.append(
|
|
70
|
+
f"{rel}: checksum drift "
|
|
71
|
+
f"(manifest={recorded[:23]}…, source={actual[:23]}…)"
|
|
72
|
+
)
|
|
73
|
+
return (0 if not errors else 1), errors
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: list[str] | None = None) -> int:
|
|
77
|
+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
78
|
+
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
|
|
79
|
+
parser.add_argument("--quiet", action="store_true")
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
|
|
82
|
+
code, errors = _check(args.manifest)
|
|
83
|
+
if code != 0:
|
|
84
|
+
for e in errors[:20]:
|
|
85
|
+
print(f"error: {e}", file=sys.stderr)
|
|
86
|
+
if len(errors) > 20:
|
|
87
|
+
print(f" ... and {len(errors) - 20} more", file=sys.stderr)
|
|
88
|
+
print(
|
|
89
|
+
"checksum-stability gate failed — run `task build-discovery` "
|
|
90
|
+
"and commit dist/discovery/.",
|
|
91
|
+
file=sys.stderr,
|
|
92
|
+
)
|
|
93
|
+
return 1
|
|
94
|
+
if not args.quiet:
|
|
95
|
+
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
|
96
|
+
print(
|
|
97
|
+
f"OK {args.manifest.relative_to(ROOT)}: "
|
|
98
|
+
f"{len(manifest['artefacts'])} artefact checksums verified."
|
|
99
|
+
)
|
|
100
|
+
return 0
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
sys.exit(main())
|
|
@@ -32,9 +32,25 @@ from dataclasses import dataclass, field
|
|
|
32
32
|
from pathlib import Path
|
|
33
33
|
|
|
34
34
|
ROOT = Path(__file__).resolve().parent.parent
|
|
35
|
-
|
|
35
|
+
sys.path.insert(0, str(ROOT / "scripts"))
|
|
36
|
+
from _lib.agent_src import resolve_logical # noqa: E402
|
|
37
|
+
|
|
36
38
|
CONTRACT = ROOT / "docs/contracts/command-clusters.md"
|
|
37
39
|
|
|
40
|
+
|
|
41
|
+
def _resolve_command(cluster: str) -> Path:
|
|
42
|
+
"""Return the physical path for ``commands/<cluster>.md``.
|
|
43
|
+
|
|
44
|
+
Walks every artefact root (legacy + packages/*) and returns the first
|
|
45
|
+
match. If none exist, returns the conventional legacy path so the
|
|
46
|
+
caller can surface a missing-file error.
|
|
47
|
+
"""
|
|
48
|
+
rel = f"commands/{cluster}.md"
|
|
49
|
+
hit = resolve_logical(rel)
|
|
50
|
+
if hit is not None:
|
|
51
|
+
return hit
|
|
52
|
+
return ROOT / ".agent-src.uncompressed" / rel
|
|
53
|
+
|
|
38
54
|
REQUIRED_SECTIONS = ["## Sub-commands", "## Dispatch", "## Rules"]
|
|
39
55
|
TABLE_HEADER_RE = re.compile(
|
|
40
56
|
r"\|\s*Sub-command\s*\|\s*Routes to\s*\|\s*Purpose\s*\|", re.IGNORECASE
|
|
@@ -87,10 +103,10 @@ def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
|
|
|
87
103
|
|
|
88
104
|
|
|
89
105
|
def check_dispatcher(cluster: str) -> FileReport:
|
|
90
|
-
path =
|
|
106
|
+
path = _resolve_command(cluster)
|
|
91
107
|
rep = FileReport(path=path, cluster=cluster)
|
|
92
108
|
if not path.exists():
|
|
93
|
-
rep.errors.append(f"dispatcher file missing: {
|
|
109
|
+
rep.errors.append(f"dispatcher file missing: commands/{cluster}.md")
|
|
94
110
|
return rep
|
|
95
111
|
text = path.read_text(encoding="utf-8")
|
|
96
112
|
fm, body = parse_frontmatter(text)
|
|
@@ -136,7 +152,7 @@ def main() -> int:
|
|
|
136
152
|
|
|
137
153
|
# Flag clusters: only assert the file exists; legacy shape is preserved.
|
|
138
154
|
flag_missing = [n for n in flag_clusters
|
|
139
|
-
if not (
|
|
155
|
+
if not _resolve_command(n).exists()]
|
|
140
156
|
if flag_missing:
|
|
141
157
|
print(f"❌ Flag-cluster file(s) missing: {flag_missing}")
|
|
142
158
|
return 1
|