@event4u/agent-config 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/.agent-src/commands/install-via-agent.md +129 -0
  2. package/.agent-src/commands/video/from-script.md +1 -1
  3. package/.agent-src/commands/video.md +1 -1
  4. package/.agent-src/contexts/execution/cheap-question-mechanics.md +81 -0
  5. package/.agent-src/rules/caveman-speak.md +2 -2
  6. package/.agent-src/rules/context-hygiene.md +36 -0
  7. package/.agent-src/rules/engineering-safety-floor.md +102 -0
  8. package/.agent-src/rules/finance-safety-floor.md +114 -0
  9. package/.agent-src/rules/git-history-discipline.md +1 -1
  10. package/.agent-src/rules/no-cheap-questions.md +34 -32
  11. package/.agent-src/rules/provider-lifecycle-discipline.md +4 -4
  12. package/.agent-src/rules/strategy-safety-floor.md +114 -0
  13. package/.agent-src/skills/agents-md-thin-root/SKILL.md +15 -9
  14. package/.agent-src/skills/async-python-patterns/SKILL.md +1 -1
  15. package/.agent-src/skills/project-analysis-node-express/SKILL.md +1 -1
  16. package/.agent-src/skills/readme-reviewer/SKILL.md +52 -3
  17. package/.agent-src/skills/readme-writing/SKILL.md +52 -4
  18. package/.agent-src/skills/readme-writing-package/SKILL.md +48 -5
  19. package/.agent-src/skills/systematic-debugging/SKILL.md +41 -0
  20. package/.agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/.agent-src/templates/hooks/pre-commit-frontmatter +66 -0
  22. package/.agent-src/templates/hooks/pre-commit-roadmap-progress +78 -39
  23. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +4 -1
  24. package/.agent-src/templates/scripts/work_engine/orchestration.py +25 -11
  25. package/.claude-plugin/marketplace.json +2 -1
  26. package/AGENTS.md +10 -8
  27. package/CHANGELOG.md +223 -125
  28. package/README.md +165 -553
  29. package/config/agent-settings.template.yml +0 -7
  30. package/config/discovery/packs.yml +20 -0
  31. package/config/discovery/unassigned-artefacts.yml +2 -0
  32. package/config/gitignore-block.txt +19 -3
  33. package/dist/cli/commands/uiServe.js +13 -4
  34. package/dist/cli/commands/uiServe.js.map +1 -1
  35. package/dist/cli/registry.js +2 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/discovery/deprecation-report.md +7 -0
  38. package/dist/discovery/discovery-manifest.json +2107 -1409
  39. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  40. package/dist/discovery/discovery-manifest.summary.md +9 -9
  41. package/dist/discovery/orphan-report.md +10 -0
  42. package/dist/discovery/packs.json +1002 -0
  43. package/dist/discovery/trust-report.md +26 -0
  44. package/dist/discovery/workspaces.json +705 -0
  45. package/dist/mcp/registry-manifest.json +4 -4
  46. package/dist/router.json +1623 -0
  47. package/dist/server/app.js +11 -3
  48. package/dist/server/app.js.map +1 -1
  49. package/dist/server/io/atomicMultiWrite.js +3 -1
  50. package/dist/server/io/atomicMultiWrite.js.map +1 -1
  51. package/dist/server/io/yamlIO.js +22 -0
  52. package/dist/server/io/yamlIO.js.map +1 -1
  53. package/dist/server/routes/ping.js +8 -0
  54. package/dist/server/routes/ping.js.map +1 -1
  55. package/dist/server/routes/schema.js +2 -2
  56. package/dist/server/routes/schema.js.map +1 -1
  57. package/dist/server/routes/settings.js +104 -23
  58. package/dist/server/routes/settings.js.map +1 -1
  59. package/dist/server/routes/userMd.js +37 -27
  60. package/dist/server/routes/userMd.js.map +1 -1
  61. package/dist/server/routes/wizard.js +256 -20
  62. package/dist/server/routes/wizard.js.map +1 -1
  63. package/dist/server/schemas/settings.js +0 -1
  64. package/dist/server/schemas/settings.js.map +1 -1
  65. package/dist/server/token.js +10 -3
  66. package/dist/server/token.js.map +1 -1
  67. package/dist/server/writeRoot.js +28 -11
  68. package/dist/server/writeRoot.js.map +1 -1
  69. package/dist/server/writeRoot.test.js +22 -4
  70. package/dist/server/writeRoot.test.js.map +1 -1
  71. package/dist/shared/userMd/formAdapter.js +29 -51
  72. package/dist/shared/userMd/formAdapter.js.map +1 -1
  73. package/dist/shared/userMd/schema.js +32 -104
  74. package/dist/shared/userMd/schema.js.map +1 -1
  75. package/dist/shared/userMd/utils.js +64 -50
  76. package/dist/shared/userMd/utils.js.map +1 -1
  77. package/dist/ui/assets/index-D-DY1ywI.js +35 -0
  78. package/dist/ui/assets/index-D-DY1ywI.js.map +1 -0
  79. package/dist/ui/index.html +1 -1
  80. package/docs/adrs/router/0001-three-tier-routing.md +5 -5
  81. package/docs/adrs/smoke/0001-per-tier-smoke-scripts.md +1 -1
  82. package/docs/architecture.md +3 -3
  83. package/docs/archive/CHANGELOG-pre-3.1.0.md +167 -0
  84. package/docs/catalog.md +30 -26
  85. package/docs/contracts/CHANGELOG-conventions.md +1 -1
  86. package/docs/contracts/agent-user-schema.md +6 -9
  87. package/docs/contracts/consumer-bridge.md +79 -0
  88. package/docs/contracts/discovery-manifest.md +209 -0
  89. package/docs/contracts/discovery-manifest.schema.json +77 -4
  90. package/docs/contracts/explain-trace.schema.json +1 -1
  91. package/docs/contracts/file-ownership-matrix.json +197 -13
  92. package/docs/contracts/frontmatter-contract.md +140 -0
  93. package/docs/contracts/gui-wizard.md +223 -0
  94. package/docs/contracts/installer-agent-mode.md +137 -0
  95. package/docs/contracts/kernel-membership.md +1 -1
  96. package/docs/contracts/mcp-tool-inventory.md +9 -9
  97. package/docs/contracts/namespace.md +6 -6
  98. package/docs/contracts/provider-lifecycle.md +5 -5
  99. package/docs/contracts/rule-router.md +4 -4
  100. package/docs/contracts/settings-api.md +53 -6
  101. package/docs/contracts/smoke-contracts.md +3 -3
  102. package/docs/contracts/trust-and-safety.md +144 -0
  103. package/docs/customization.md +2 -2
  104. package/docs/decisions/ADR-007-agent-discovery-scopes.md +12 -0
  105. package/docs/decisions/ADR-013-discovery-frontmatter-contract.md +24 -0
  106. package/docs/decisions/ADR-015-discovery-manifest-contract.md +146 -0
  107. package/docs/decisions/ADR-016-installer-architecture.md +189 -0
  108. package/docs/decisions/ADR-017-monorepo-physical-layout.md +261 -0
  109. package/docs/decisions/ADR-018-trust-and-safety-layer.md +159 -0
  110. package/docs/decisions/ADR-019-router-json-dist-location.md +124 -0
  111. package/docs/decisions/ADR-020-global-only-consumer-scope.md +123 -0
  112. package/docs/decisions/ADR-021-deployment-shape.md +153 -0
  113. package/docs/decisions/INDEX.md +7 -0
  114. package/docs/deploy/connector-setup.md +129 -0
  115. package/docs/deploy/env-vars.md +70 -0
  116. package/docs/deploy/policy-cookbook.md +130 -0
  117. package/docs/deploy/quickstart.md +112 -0
  118. package/docs/distribution/public-install-smoke.md +68 -0
  119. package/docs/distribution/registries.md +55 -0
  120. package/docs/distribution/telemetry-privacy.md +128 -0
  121. package/docs/distribution/telemetry-schema.md +174 -0
  122. package/docs/featured-skills.md +95 -0
  123. package/docs/getting-started-by-role.md +19 -1
  124. package/docs/getting-started.md +2 -2
  125. package/docs/guidelines/agent-infra/installed-tools-manifest.md +11 -8
  126. package/docs/guidelines/docs/readme-size-and-splitting.md +53 -1
  127. package/docs/installation.md +27 -14
  128. package/docs/maintainers/dev-mode.md +105 -0
  129. package/docs/setup/per-ide/claude-desktop.md +3 -2
  130. package/docs/wizard.md +39 -4
  131. package/package.json +18 -1
  132. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  133. package/scripts/_cli/cmd_doctor.py +150 -2
  134. package/scripts/_cli/cmd_explain.py +2 -1
  135. package/scripts/_cli/cmd_migrate_to_global.py +415 -0
  136. package/scripts/_cli/cmd_settings_migrate.py +146 -0
  137. package/scripts/_cli/explain_last/route.py +2 -1
  138. package/scripts/_dispatch.bash +36 -3
  139. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  140. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  141. package/scripts/_lib/agent_settings.py +4 -1
  142. package/scripts/_lib/agent_src.py +157 -0
  143. package/scripts/agent-config +17 -6
  144. package/scripts/audit_skill_descriptions.py +18 -6
  145. package/scripts/build_discovery_manifest.py +373 -17
  146. package/scripts/check_artefact_checksums.py +104 -0
  147. package/scripts/check_cluster_patterns.py +20 -4
  148. package/scripts/check_command_count_messaging.py +33 -14
  149. package/scripts/check_council_references.py +43 -4
  150. package/scripts/check_overlay_cascade_subdirs.py +7 -3
  151. package/scripts/check_references.py +5 -2
  152. package/scripts/check_reply_consistency.py +32 -9
  153. package/scripts/check_template_pin_drift.py +24 -7
  154. package/scripts/check_token_optimizer_freshness.py +18 -3
  155. package/scripts/compile_router.py +34 -2
  156. package/scripts/compress.py +162 -44
  157. package/scripts/config/presets.py +19 -1
  158. package/scripts/config/profiles.py +16 -1
  159. package/scripts/discovery_stats.py +70 -0
  160. package/scripts/expected_perms.json +47 -0
  161. package/scripts/generate_index.py +78 -46
  162. package/scripts/generate_ownership_matrix.py +98 -43
  163. package/scripts/generate_pack_manifests.py +183 -0
  164. package/scripts/install +18 -1
  165. package/scripts/install.py +934 -59
  166. package/scripts/install.sh +27 -9
  167. package/scripts/lint_agents_layout.py +93 -13
  168. package/scripts/lint_agents_md.py +1 -1
  169. package/scripts/lint_archived_skills.py +32 -16
  170. package/scripts/lint_bench_corpus.py +14 -2
  171. package/scripts/lint_command_tiers.py +15 -2
  172. package/scripts/lint_featured_skills.py +139 -0
  173. package/scripts/lint_framework_leakage.py +33 -6
  174. package/scripts/lint_global_paths.py +147 -0
  175. package/scripts/lint_orchestration_dsl.py +6 -3
  176. package/scripts/lint_pack_boundaries.py +147 -0
  177. package/scripts/lint_pack_first_win.py +103 -0
  178. package/scripts/lint_readme_jargon.py +131 -0
  179. package/scripts/lint_readme_size.py +33 -0
  180. package/scripts/lint_rule_interactions.py +23 -5
  181. package/scripts/lint_rule_tiers.py +12 -3
  182. package/scripts/lint_trust_coherence.py +212 -0
  183. package/scripts/measure_rule_budget.py +22 -4
  184. package/scripts/move_artefact.py +143 -0
  185. package/scripts/new_skill.py +148 -0
  186. package/scripts/plan_physical_move.py +353 -0
  187. package/scripts/refine_ticket_detect.py +30 -7
  188. package/scripts/schemas/command.schema.json +4 -0
  189. package/scripts/skill_linter.py +248 -118
  190. package/scripts/skill_trigger_eval.py +28 -8
  191. package/scripts/smoke/kernel.sh +1 -1
  192. package/scripts/smoke/router.sh +24 -5
  193. package/scripts/smoke/skills.sh +15 -7
  194. package/scripts/smoke_quickstart.py +11 -2
  195. package/scripts/snapshot_agent_outputs.py +144 -0
  196. package/scripts/update_counts.py +45 -17
  197. package/scripts/validate_decision_engine.py +9 -1
  198. package/scripts/validate_discovery_manifest.py +94 -0
  199. package/scripts/validate_frontmatter.py +39 -20
  200. package/scripts/verify_physical_move.py +185 -0
  201. package/templates/agent-user.md +0 -1
  202. package/templates/agent-user.yml +21 -0
  203. package/templates/minimal/agents-overrides-readme.md +46 -0
  204. package/templates/minimal/overrides-gitkeep +2 -0
  205. package/dist/ui/assets/index-BTRcKDlB.js +0 -39
  206. package/dist/ui/assets/index-BTRcKDlB.js.map +0 -1
  207. 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
- TRUST_ROOTS = (".agent-src.uncompressed", ".augment", ".claude", ".agent-src")
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 = {e["path"]: e["reason"] for e in raw_un} if raw_un else {}
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
- for p in sorted((SRC / "skills").rglob("SKILL.md")):
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 sorted((SRC / "rules").rglob("*.md")):
136
+ for p in _collect("rules", "*.md"):
64
137
  yield p, "rule"
65
- for p in sorted((SRC / "commands").rglob("*.md")):
138
+ for p in _collect("commands", "*.md"):
66
139
  yield p, "command"
67
- if (SRC / "templates").exists():
68
- for p in sorted((SRC / "templates").rglob("*.md")):
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 ("active", "deprecated", "experimental", "archived"):
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 ("core", "professional", "experimental", "advisory", "restricted"):
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
- return {
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
- }, None
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": p["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(p["id"], 0),
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
- COMMANDS_DIR = ROOT / ".agent-src.uncompressed/commands"
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 = COMMANDS_DIR / f"{cluster}.md"
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: {path.relative_to(ROOT)}")
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 (COMMANDS_DIR / f"{n}.md").exists()]
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