@event4u/agent-config 4.9.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.agent-src/commands/implement-ticket.md +5 -4
  2. package/.agent-src/rules/language-and-tone.md +4 -10
  3. package/.agent-src/skills/command-routing/SKILL.md +5 -4
  4. package/.claude-plugin/marketplace.json +1 -1
  5. package/CHANGELOG.md +73 -0
  6. package/CONTRIBUTING.md +19 -0
  7. package/README.md +11 -0
  8. package/dist/cli/registry.js +0 -2
  9. package/dist/cli/registry.js.map +1 -1
  10. package/dist/discovery/deprecation-report.md +1 -1
  11. package/dist/discovery/discovery-manifest.json +5 -5
  12. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  13. package/dist/discovery/discovery-manifest.summary.md +1 -1
  14. package/dist/discovery/orphan-report.md +1 -1
  15. package/dist/discovery/packs.json +2 -2
  16. package/dist/discovery/trust-report.md +1 -1
  17. package/dist/discovery/workspaces.json +2 -2
  18. package/dist/mcp/registry-manifest.json +2 -2
  19. package/dist/router.json +1 -1671
  20. package/docs/benchmark.md +20 -8
  21. package/docs/benchmarks.md +11 -0
  22. package/docs/contracts/benchmark-corpus-spec.md +31 -3
  23. package/docs/contracts/command-surface-tiers.md +1 -1
  24. package/docs/contracts/hook-architecture-v1.md +33 -0
  25. package/docs/contracts/migrate-command.md +197 -0
  26. package/docs/contracts/settings-api.md +2 -1
  27. package/docs/contracts/value-dashboard-spec.md +374 -0
  28. package/docs/contracts/value-report-schema.md +150 -0
  29. package/docs/decisions/ADR-031-validation-severity-tiers-and-projection-roundtrip.md +97 -0
  30. package/docs/decisions/INDEX.md +1 -0
  31. package/docs/guidelines/agent-infra/installed-tools-manifest.md +6 -3
  32. package/docs/guidelines/agent-infra/language-and-tone-examples.md +35 -0
  33. package/docs/migration/v1-to-v2.md +40 -27
  34. package/docs/value.md +84 -0
  35. package/package.json +8 -8
  36. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  37. package/scripts/_cli/cmd_migrate.py +264 -102
  38. package/scripts/_cli/cmd_settings_migrate.py +2 -1
  39. package/scripts/_dispatch.bash +147 -49
  40. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  41. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  42. package/scripts/_lib/install_regenerator.py +129 -0
  43. package/scripts/_lib/value_ladder.py +599 -0
  44. package/scripts/_lib/value_report.py +441 -0
  45. package/scripts/bench_rtk_savings.py +320 -0
  46. package/scripts/compile_router.py +19 -5
  47. package/scripts/expected_perms.json +1 -1
  48. package/scripts/first_run_gate_hook.py +178 -0
  49. package/scripts/hook_manifest.yaml +16 -7
  50. package/scripts/hooks/dispatch_hook.py +27 -0
  51. package/scripts/hooks/dispatch_issues.py +136 -0
  52. package/scripts/hooks_doctor.py +40 -1
  53. package/scripts/install.py +25 -21
  54. package/scripts/lint_agents_layout.py +5 -4
  55. package/scripts/lint_bench_corpus.py +86 -4
  56. package/scripts/lint_global_paths.py +4 -3
  57. package/scripts/lint_marketplace_install_completeness.py +188 -0
  58. package/scripts/lint_value_dashboard.py +218 -0
  59. package/scripts/render_benchmark_md.py +6 -2
  60. package/scripts/render_value_md.py +355 -0
  61. package/scripts/repro/repro_marketplace_install_gap.sh +161 -0
  62. package/scripts/roadmap_progress_hook.py +23 -0
  63. package/scripts/router_telemetry.py +470 -0
  64. package/scripts/validate_frontmatter.py +23 -9
  65. package/scripts/_cli/cmd_migrate_to_global.py +0 -415
@@ -0,0 +1,136 @@
1
+ """Append-only log of dispatch-time issues — Phase 1 of `road-to-hooks-actually-fire-in-consumers`.
2
+
3
+ When a concern's resolver returns `None` (script missing, regenerator
4
+ missing, `./agent-config` symlink unresolvable) the dispatcher (or the
5
+ concern hook itself, when invoked as a subprocess) records ONE line in
6
+ `agents/runtime/state/dispatch-issues.jsonl` so the failure is
7
+ discoverable post-hoc instead of vanishing into the never-block
8
+ contract.
9
+
10
+ **Schema** (locked by Council R3 pre-check, 2026-05-29):
11
+
12
+ {
13
+ "timestamp": "<ISO-8601 UTC>",
14
+ "hook": "<concern-id>",
15
+ "issue": "prerequisite_missing | script_not_found | "
16
+ "permission_denied | execution_failed",
17
+ "detail": "<freeform one-line explanation>",
18
+ "resolution": "<one-line command or doc link>"
19
+ }
20
+
21
+ **Cap:** 200 entries (council-revised from the original 50; debug
22
+ sessions with many tool calls would have lost evidence at the old
23
+ cap). Rotation drops the oldest line.
24
+
25
+ Errors writing the log are swallowed — observability never breaks
26
+ the agent loop.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import sys
33
+ from datetime import datetime, timezone
34
+ from pathlib import Path
35
+ from typing import Optional
36
+
37
+
38
+ LOG_CAP = 200
39
+
40
+ VALID_ISSUE = frozenset({
41
+ "prerequisite_missing",
42
+ "script_not_found",
43
+ "permission_denied",
44
+ "execution_failed",
45
+ })
46
+
47
+
48
+ def _utc_iso() -> str:
49
+ return datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
50
+ "+00:00", "Z"
51
+ )
52
+
53
+
54
+ def _log_path(workspace_root: Path) -> Path:
55
+ return Path(workspace_root) / "agents" / "runtime" / "state" / "dispatch-issues.jsonl"
56
+
57
+
58
+ def log_dispatch_issue(
59
+ workspace_root: Path,
60
+ hook: str,
61
+ issue: str,
62
+ detail: str,
63
+ resolution: str,
64
+ ) -> None:
65
+ """Append one dispatch-issue line. Best-effort; never raises.
66
+
67
+ No-op when `AGENT_CONFIG_REPLAY=1` is set — fixture-driven replay
68
+ must not mutate state (contract: `docs/contracts/hook-architecture-v1.md`
69
+ § Replay mode).
70
+ """
71
+ if os.environ.get("AGENT_CONFIG_REPLAY") == "1":
72
+ return
73
+
74
+ if issue not in VALID_ISSUE:
75
+ # Schema violation is a bug in the caller, not a runtime
76
+ # failure — surface on stderr so it's noticed during dev, but
77
+ # do not crash.
78
+ sys.stderr.write(
79
+ f"dispatch_issues: invalid issue {issue!r} (valid: "
80
+ f"{sorted(VALID_ISSUE)})\n"
81
+ )
82
+ return
83
+
84
+ log = _log_path(workspace_root)
85
+ entry = {
86
+ "timestamp": _utc_iso(),
87
+ "hook": str(hook),
88
+ "issue": issue,
89
+ "detail": str(detail),
90
+ "resolution": str(resolution),
91
+ }
92
+
93
+ try:
94
+ log.parent.mkdir(parents=True, exist_ok=True)
95
+ # Read existing lines (cheap — bounded log).
96
+ existing: list[str] = []
97
+ if log.exists():
98
+ try:
99
+ existing = log.read_text(encoding="utf-8").splitlines()
100
+ except OSError:
101
+ existing = []
102
+ existing.append(json.dumps(entry, ensure_ascii=False))
103
+ # Cap rotation: drop the oldest entries.
104
+ if len(existing) > LOG_CAP:
105
+ existing = existing[-LOG_CAP:]
106
+ log.write_text("\n".join(existing) + "\n", encoding="utf-8")
107
+ except OSError as exc:
108
+ # Observability never blocks the agent.
109
+ sys.stderr.write(
110
+ f"dispatch_issues: failed to append to {log}: {exc}\n"
111
+ )
112
+
113
+
114
+ def read_dispatch_issues(workspace_root: Path) -> list[dict]:
115
+ """Return the log as a list of dicts. Empty list when missing."""
116
+ log = _log_path(workspace_root)
117
+ if not log.exists():
118
+ return []
119
+ out: list[dict] = []
120
+ try:
121
+ for line in log.read_text(encoding="utf-8").splitlines():
122
+ line = line.strip()
123
+ if not line:
124
+ continue
125
+ try:
126
+ out.append(json.loads(line))
127
+ except json.JSONDecodeError:
128
+ continue
129
+ except OSError:
130
+ return []
131
+ return out
132
+
133
+
134
+ def fix_hint(workspace_root: Optional[Path] = None) -> str:
135
+ """Best-known fix hint string. Returned for use in `resolution` field."""
136
+ return "./agent-config init"
@@ -111,16 +111,41 @@ def collect(project_root: Path, manifest: dict,
111
111
  "missing": needs_trampoline and not tpath.is_file(),
112
112
  })
113
113
 
114
+ # Phase 1 of road-to-hooks-actually-fire-in-consumers: surface
115
+ # the dispatch-issues log so users see hooks that tried and failed.
116
+ state_root = REPO_ROOT / STATE_DIR_DEFAULT
117
+ issues: list[dict] = []
118
+ try:
119
+ sys.path.insert(0, str(REPO_ROOT / "scripts" / "hooks"))
120
+ from dispatch_issues import read_dispatch_issues # noqa: PLC0415
121
+ issues = read_dispatch_issues(REPO_ROOT)[-20:] # last 20
122
+ except (ImportError, OSError):
123
+ issues = []
124
+
114
125
  return {
115
126
  "schema_version": 1,
116
127
  "platforms": matrix["platforms"],
117
128
  "concerns": concerns,
118
129
  "trampolines": trampolines,
130
+ "dispatch_issues": issues,
119
131
  }
120
132
 
121
133
 
122
134
  def _render_table(payload: dict) -> str:
123
- lines: list[str] = [hooks_status._render_table(payload), ""]
135
+ lines: list[str] = []
136
+ # Phase 1 CTA — surfaces at the TOP when issues exist, so a user
137
+ # reading the report can't miss it.
138
+ if payload.get("dispatch_issues"):
139
+ n = len(payload["dispatch_issues"])
140
+ lines.append(
141
+ f"⚠️ Hooks tried to fire but couldn't ({n} entry"
142
+ f"{'ies' if n != 1 else 'y'} in dispatch-issues.jsonl) — "
143
+ "run `./agent-config hooks:install --claude --regen` "
144
+ "(or follow the per-concern hints below)"
145
+ )
146
+ lines.append("")
147
+ lines.append(hooks_status._render_table(payload))
148
+ lines.append("")
124
149
  lines.append("Concerns")
125
150
  lines.append("-" * 60)
126
151
  for c in payload["concerns"]:
@@ -138,6 +163,20 @@ def _render_table(payload: dict) -> str:
138
163
  marker = "❌ " if t["missing"] else ("· " if not t["required"] else "✅ ")
139
164
  suffix = "" if t["required"] else " (not required)"
140
165
  lines.append(f"{marker}{t['platform']:<9} {t['expected']}{suffix}")
166
+ # Dispatch-issues detail — last 20 grouped by concern.
167
+ if payload.get("dispatch_issues"):
168
+ lines.append("")
169
+ lines.append("Dispatch issues (last 20)")
170
+ lines.append("-" * 60)
171
+ grouped: dict[str, list[dict]] = {}
172
+ for entry in payload["dispatch_issues"]:
173
+ grouped.setdefault(entry.get("hook", "?"), []).append(entry)
174
+ for hook, entries in sorted(grouped.items()):
175
+ lines.append(f"⚠️ {hook}: {len(entries)} issue(s)")
176
+ # Show the most recent reason + resolution per concern.
177
+ latest = entries[-1]
178
+ lines.append(f" {latest.get('issue')}: {latest.get('detail')}")
179
+ lines.append(f" fix → {latest.get('resolution')}")
141
180
  return "\n".join(lines)
142
181
 
143
182
 
@@ -3009,13 +3009,14 @@ CONSUMER_BRIDGE_MARKER_RELPATH = Path("agents") / ".event4u-bridge.yml"
3009
3009
 
3010
3010
 
3011
3011
  # ---------------------------------------------------------------------------
3012
- # Phase 5.2 — migrate-to-global first-run hook
3012
+ # First-run migration hook
3013
3013
  # ---------------------------------------------------------------------------
3014
3014
  #
3015
3015
  # Legacy artefacts that signal a pre-ADR-020 install in the project root.
3016
- # Same surface the ``migrate-to-global`` command detects (see
3017
- # ``scripts/_cli/cmd_migrate_to_global.py``). Kept in sync intentionally so
3018
- # the prompt and the migration tool agree on what counts as "legacy".
3016
+ # Same surface the unified ``migrate`` command detects (see
3017
+ # ``scripts/_cli/cmd_migrate.py`` and ``docs/contracts/migrate-command.md``).
3018
+ # Kept in sync intentionally so the prompt and the migration tool agree on
3019
+ # what counts as "legacy".
3019
3020
  MIGRATE_LEGACY_YAML_FILES = (".agent-settings.yml", ".agent-user.yml")
3020
3021
  MIGRATE_LEGACY_TOOL_DIRS = (".augment", ".claude", ".cursor")
3021
3022
 
@@ -3125,19 +3126,22 @@ def _detect_legacy_for_migration(project_root: Path) -> list[str]:
3125
3126
 
3126
3127
 
3127
3128
  def _prompt_migrate_to_global(project_root: Path, artefacts: list[str]) -> bool:
3128
- """Ask the user whether to run ``migrate-to-global`` now.
3129
+ """Ask the user whether to run the unified ``migrate`` command now.
3129
3130
 
3130
3131
  Interactive TTY → ``[Y/n]`` prompt (Enter = yes). Non-interactive (CI
3131
- or no TTY) → auto-yes per roadmap Phase 5.2 contract. Three invalid
3132
- replies short-circuit to "no" (defensive, never blocks the install).
3132
+ or no TTY) → auto-yes. Three invalid replies short-circuit to "no"
3133
+ (defensive, never blocks the install). The function name is kept for
3134
+ compatibility with the install flow; the legacy ``migrate-to-global``
3135
+ command was collapsed into the unified ``migrate`` (see
3136
+ ``docs/contracts/migrate-command.md``).
3133
3137
  """
3134
3138
  if not QUIET:
3135
3139
  print()
3136
3140
  warn("Legacy project-local artefacts detected — pre-ADR-020 layout:")
3137
3141
  for rel in artefacts:
3138
3142
  info(f" {project_root / rel}")
3139
- info("ADR-020 ships consumer installs as global-only.")
3140
- info("`agent-config migrate-to-global` copies verifies → moves them safely.")
3143
+ info("The unified `agent-config migrate` sweeps these in one pass.")
3144
+ info("The wizard recreates fresh config afterwards.")
3141
3145
 
3142
3146
  if not _is_interactive():
3143
3147
  if not QUIET:
@@ -3147,7 +3151,7 @@ def _prompt_migrate_to_global(project_root: Path, artefacts: list[str]) -> bool:
3147
3151
  attempts = 0
3148
3152
  while attempts < 3:
3149
3153
  try:
3150
- reply = _read_line("Run `agent-config migrate-to-global` now? [Y/n]: ")
3154
+ reply = _read_line("Run `agent-config migrate` now? [Y/n]: ")
3151
3155
  except EOFError:
3152
3156
  return False
3153
3157
  if reply == "" or reply.lower() in ("y", "yes"):
@@ -3160,23 +3164,22 @@ def _prompt_migrate_to_global(project_root: Path, artefacts: list[str]) -> bool:
3160
3164
 
3161
3165
 
3162
3166
  def _run_migrate_to_global(project_root: Path) -> int:
3163
- """Invoke ``cmd_migrate_to_global._do_migrate`` against ``project_root``.
3167
+ """Invoke the unified ``cmd_migrate`` against ``project_root``.
3164
3168
 
3165
3169
  Returns the migrator's exit code so the caller can abort the install
3166
- on failure. The perms gate is skipped because the install path runs
3167
- its own checks; surfacing two perm errors back-to-back would be
3168
- confusing for first-run users.
3170
+ on failure. The function name is kept for compatibility with the
3171
+ install flow; the legacy ``migrate-to-global`` command was collapsed
3172
+ into the unified ``migrate`` (see ``docs/contracts/migrate-command.md``).
3169
3173
  """
3170
3174
  import importlib # noqa: PLC0415 — local to keep startup lean.
3171
3175
 
3172
3176
  try:
3173
- cmd_mod = importlib.import_module("scripts._cli.cmd_migrate_to_global")
3177
+ cmd_mod = importlib.import_module("scripts._cli.cmd_migrate")
3174
3178
  except ImportError as exc:
3175
- warn(f"migrate-to-global unavailable: {exc}")
3179
+ warn(f"migrate unavailable: {exc}")
3176
3180
  return 1
3177
3181
 
3178
- install_mod = sys.modules[__name__]
3179
- return cmd_mod._do_migrate(project_root, force=False, install_mod=install_mod, out=sys.stdout)
3182
+ return cmd_mod.main([], cwd=project_root, out=sys.stdout)
3180
3183
 
3181
3184
 
3182
3185
  def _format_global_root_for_marker(global_root: Path) -> str:
@@ -4976,9 +4979,10 @@ def main(argv: list[str]) -> int:
4976
4979
 
4977
4980
  try:
4978
4981
  if scope == "global":
4979
- # Phase 5.2 — first-run hook: when legacy artefacts live in the
4980
- # project tree, prompt before laying down the global surface so
4981
- # the user is not left with a dual-stack install.
4982
+ # First-run hook: when legacy artefacts live in the project tree,
4983
+ # prompt before laying down the global surface so the user is
4984
+ # not left with a dual-stack install. Delegates to the unified
4985
+ # `agent-config migrate` (see docs/contracts/migrate-command.md).
4982
4986
  artefacts = _detect_legacy_for_migration(detect_root)
4983
4987
  if artefacts and _prompt_migrate_to_global(detect_root, artefacts):
4984
4988
  rc = _run_migrate_to_global(detect_root)
@@ -72,10 +72,11 @@ CONSUMER_EXPECTED_ENTRIES: frozenset[str] = frozenset(
72
72
  )
73
73
 
74
74
  MIGRATE_HINT = (
75
- "Run `agent-config settings migrate` (or `npx @event4u/agent-config "
76
- "migrate-to-global`) to move legacy project-scope artefacts under "
77
- "`~/.event4u/agent-config/` and leave `agents/overrides/` + "
78
- "`agents/.event4u-bridge.yml` as the only consumer-side files."
75
+ "Run `npx @event4u/agent-config migrate` to sweep legacy project-scope "
76
+ "artefacts in one pass. The unified `migrate` command (see "
77
+ "`docs/contracts/migrate-command.md`) leaves `agents/overrides/` + "
78
+ "`agents/.event4u-bridge.yml` as the only consumer-side files; the "
79
+ "wizard recreates fresh config on `agent-config setup`."
79
80
  )
80
81
 
81
82
 
@@ -23,6 +23,7 @@ Flags:
23
23
  """
24
24
  from __future__ import annotations
25
25
 
26
+ import json
26
27
  import re
27
28
  import sys
28
29
  from pathlib import Path
@@ -38,6 +39,8 @@ REQUIRE_FULL = "--require-full" in sys.argv
38
39
 
39
40
  REPO = Path(__file__).resolve().parents[1]
40
41
  CORPUS_DIR = REPO / "tests" / "eval"
42
+ ROUTER_COVERAGE_DIR = REPO / "internal" / "bench" / "corpora" / "router-coverage"
43
+ ROUTER_JSON = REPO / "dist" / "router.json"
41
44
 
42
45
  # Live skill directories live under every artefact root post-monorepo
43
46
  # Phase 4 (legacy + packages/*/.agent-src.uncondensed/skills/).
@@ -46,7 +49,7 @@ from _lib.agent_src import artefact_roots # noqa: E402
46
49
 
47
50
  SKILLS_DIRS = [root / "skills" for root in artefact_roots() if (root / "skills").is_dir()]
48
51
 
49
- VALID_CATEGORIES = frozenset({"canonical", "ambiguous", "destructive", "long-context"})
52
+ VALID_CATEGORIES = frozenset({"canonical", "ambiguous", "destructive", "long-context", "router-coverage"})
50
53
  # Non-dev corpus (pre-spec) uses legacy categories — accept them so the
51
54
  # new linter does not break that file. Migration is a follow-up.
52
55
  LEGACY_CATEGORIES = frozenset({"content", "consulting", "finance", "ops", "safety"})
@@ -66,7 +69,40 @@ def live_skills() -> set[str]:
66
69
  return slugs
67
70
 
68
71
 
69
- def lint_corpus(path: Path, skills: set[str]) -> list[str]:
72
+ def live_rule_ids() -> set[str] | None:
73
+ """Return all rule ids known to dist/router.json (kernel + tier_1 + tier_2).
74
+
75
+ Returns ``None`` (not an empty set) when the router is missing or
76
+ unparseable, signalling "cannot validate rule ids — skip the
77
+ unknown-trigger checks" rather than "every referenced id is unknown".
78
+ A missing router is expected on a fresh clone before ``task sync``;
79
+ returning an empty set there would falsely flag every intended /
80
+ opaque trigger as ``unknown_intended_trigger``.
81
+ """
82
+ if not ROUTER_JSON.exists():
83
+ sys.stderr.write(
84
+ f"warning: {ROUTER_JSON.relative_to(REPO)} missing — skipping "
85
+ "trigger rule-id validation (run `task sync` to generate it)\n"
86
+ )
87
+ return None
88
+ try:
89
+ data = json.loads(ROUTER_JSON.read_text(encoding="utf-8"))
90
+ except json.JSONDecodeError:
91
+ sys.stderr.write(
92
+ f"warning: {ROUTER_JSON.relative_to(REPO)} unparseable — "
93
+ "skipping trigger rule-id validation\n"
94
+ )
95
+ return None
96
+ ids: set[str] = set()
97
+ ids.update(data.get("kernel", []) or [])
98
+ for tier in ("tier_1", "tier_2"):
99
+ ids.update(
100
+ r.get("id") for r in (data.get(tier, []) or []) if r.get("id")
101
+ )
102
+ return ids
103
+
104
+
105
+ def lint_corpus(path: Path, skills: set[str], rule_ids: set[str] | None = None) -> list[str]:
70
106
  errors: list[str] = []
71
107
  try:
72
108
  data = yaml.safe_load(path.read_text(encoding="utf-8"))
@@ -121,7 +157,12 @@ def lint_corpus(path: Path, skills: set[str]) -> list[str]:
121
157
  errors.append(f"{loc}: empty_prompt")
122
158
 
123
159
  expected = p.get("expected_skills") or []
124
- if not isinstance(expected, list) or not expected:
160
+ if not isinstance(expected, list):
161
+ errors.append(f"{loc}: bad_expected_shape")
162
+ elif not expected and cat != "router-coverage":
163
+ # router-coverage corpora can have empty expected_skills —
164
+ # the focus is rule-trigger activation, not skill selection.
165
+ # The intended_triggers field is the load-bearing assertion.
125
166
  errors.append(f"{loc}: empty_expected")
126
167
  else:
127
168
  for slug in expected:
@@ -133,6 +174,40 @@ def lint_corpus(path: Path, skills: set[str]) -> list[str]:
133
174
  if not isinstance(carve, list) or not carve:
134
175
  errors.append(f"{loc}: missing_carve_out")
135
176
 
177
+ # router-coverage invariants (Council R3 honesty floor).
178
+ # A task's trigger prediction lives in two buckets:
179
+ # intended_triggers — deterministically replayable (keyword /
180
+ # phrase / command / path with supplied
181
+ # open_files or command context).
182
+ # replay_opaque_triggers — fires at runtime only via an `intent`
183
+ # trigger (or a router coverage gap) the
184
+ # static replay cannot verify. Declared so
185
+ # the telemetry reports it separately, not
186
+ # as false `missed_intended` drift.
187
+ # router-coverage requires at least one bucket non-empty.
188
+ intended = p.get("intended_triggers")
189
+ opaque = p.get("replay_opaque_triggers")
190
+ intended_list = intended if isinstance(intended, list) else []
191
+ opaque_list = opaque if isinstance(opaque, list) else []
192
+
193
+ if intended is not None and not isinstance(intended, list):
194
+ errors.append(f"{loc}: bad_intended_triggers_shape")
195
+ if opaque is not None and not isinstance(opaque, list):
196
+ errors.append(f"{loc}: bad_replay_opaque_triggers_shape")
197
+
198
+ if cat == "router-coverage" and not intended_list and not opaque_list:
199
+ errors.append(f"{loc}: missing_intended_triggers")
200
+
201
+ # A rule belongs to exactly one bucket — both is a contradiction.
202
+ for rid in sorted(set(intended_list) & set(opaque_list)):
203
+ errors.append(f"{loc}: trigger_in_both_buckets: {rid}")
204
+
205
+ # Every referenced id (either bucket) must be a real router rule id.
206
+ if rule_ids is not None:
207
+ for rid in intended_list + opaque_list:
208
+ if rid not in rule_ids:
209
+ errors.append(f"{loc}: unknown_intended_trigger: {rid}")
210
+
136
211
  if REQUIRE_FULL and not is_legacy:
137
212
  for bucket, want in FULL_COUNTS.items():
138
213
  have = bucket_counts.get(bucket, 0)
@@ -147,14 +222,21 @@ def main() -> int:
147
222
  sys.stderr.write(f"error: corpus dir missing: {CORPUS_DIR}\n")
148
223
  return 2
149
224
  corpora = sorted(CORPUS_DIR.glob("corpus-*.yaml"))
225
+ # Phase 2 of road-to-corpus-expansion-evidence-based-cuts adds a second
226
+ # corpus tree under internal/bench/corpora/router-coverage/. Linter scans
227
+ # both with the same invariants — router-coverage corpora additionally
228
+ # require `intended_triggers` per prompt.
229
+ if ROUTER_COVERAGE_DIR.is_dir():
230
+ corpora.extend(sorted(ROUTER_COVERAGE_DIR.glob("*.yaml")))
150
231
  if not corpora:
151
232
  sys.stderr.write("error: no corpora found\n")
152
233
  return 2
153
234
 
154
235
  skills = live_skills()
236
+ rule_ids = live_rule_ids()
155
237
  all_errors: list[str] = []
156
238
  for path in corpora:
157
- errs = lint_corpus(path, skills)
239
+ errs = lint_corpus(path, skills, rule_ids)
158
240
  if errs:
159
241
  all_errors.extend(errs)
160
242
  elif not QUIET:
@@ -3,9 +3,10 @@
3
3
 
4
4
  Phase 5.0 / amendment A7 of road-to-global-only-install. Runs BEFORE
5
5
  any legacy snapshot write so a perms leak cannot be created by the
6
- migration itself: `agent-config migrate-to-global` is expected to call
7
- this script first, abort on any failure, and only then proceed with
8
- the copy verify move → bridge sequence.
6
+ migration itself. Historically invoked by `agent-config migrate-to-global`;
7
+ that command was collapsed into `agent-config migrate` (see
8
+ `docs/contracts/migrate-command.md`). The audit now runs standalone via
9
+ `agent-config doctor` or directly through this script.
9
10
 
10
11
  Policy source: scripts/expected_perms.json (parameterised so the policy
11
12
  can evolve without code changes).
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """Lint that every command in `hooks/hooks.json` resolves to a real
3
+ dispatcher subcommand in `scripts/_dispatch.bash`.
4
+
5
+ Phase 6 of `road-to-hooks-actually-fire-in-consumers`.
6
+
7
+ The linter checks **plugin-side completeness** — the package ships a
8
+ valid `hooks.json` whose every command line points at a subcommand
9
+ the dispatcher knows about. It does NOT check consumer-side
10
+ scaffolding (that's the runtime `dispatch-issues.jsonl` log's job
11
+ from Phase 1).
12
+
13
+ This distinction is load-bearing — see Council R3 finding #1:
14
+ "A valid plugin against an unscaffolded consumer is a PASS;
15
+ the linter must not produce a false-positive on that state."
16
+
17
+ Exit codes:
18
+ 0 — every command resolves; clean.
19
+ 1 — at least one command references an unknown subcommand.
20
+ 2 — schema / file error.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import re
27
+ import sys
28
+ from pathlib import Path
29
+
30
+
31
+ REPO_ROOT = Path(__file__).resolve().parent.parent
32
+ HOOKS_JSON = REPO_ROOT / "hooks" / "hooks.json"
33
+ DISPATCH_BASH = REPO_ROOT / "scripts" / "_dispatch.bash"
34
+
35
+
36
+ # Map agent-config-cli subcommand → dispatcher function name. The
37
+ # subcommand is what appears after `./agent-config <subcommand>` in
38
+ # the hooks.json command line; the function is what's defined in
39
+ # _dispatch.bash. The user-facing subcommand uses colons; the
40
+ # function uses underscores (e.g. `dispatch:hook` → `cmd_dispatch_hook`).
41
+ def subcommand_to_function(subcommand: str) -> str:
42
+ # Normalise: replace `:` and `-` with `_`.
43
+ sanitised = subcommand.replace(":", "_").replace("-", "_")
44
+ return f"cmd_{sanitised}"
45
+
46
+
47
+ def load_hook_commands(hooks_path: Path) -> list[tuple[str, str]]:
48
+ """Return [(event_name, command_line)] for every hook entry."""
49
+ try:
50
+ data = json.loads(hooks_path.read_text(encoding="utf-8"))
51
+ except (OSError, json.JSONDecodeError) as exc:
52
+ raise SystemExit(f"lint-marketplace-install: cannot read {hooks_path}: {exc}")
53
+
54
+ hooks = data.get("hooks") or {}
55
+ if not isinstance(hooks, dict):
56
+ raise SystemExit(f"lint-marketplace-install: {hooks_path} `hooks` is not an object")
57
+
58
+ out: list[tuple[str, str]] = []
59
+ for event, groups in hooks.items():
60
+ if not isinstance(groups, list):
61
+ continue
62
+ for group in groups:
63
+ if not isinstance(group, dict):
64
+ continue
65
+ for entry in group.get("hooks", []) or []:
66
+ if not isinstance(entry, dict):
67
+ continue
68
+ cmd = entry.get("command")
69
+ if isinstance(cmd, str) and cmd.strip():
70
+ out.append((str(event), cmd))
71
+ return out
72
+
73
+
74
+ # Pattern: `"$CLAUDE_PROJECT_DIR"/agent-config <subcommand> [args...]`.
75
+ # Accepts both quoted and bare CLAUDE_PROJECT_DIR.
76
+ _CMD_RE = re.compile(
77
+ r'(?:"?\$\{?CLAUDE_PROJECT_DIR\}?"?/)?agent-config\s+([a-zA-Z0-9:_-]+)'
78
+ )
79
+
80
+
81
+ def extract_subcommand(command_line: str) -> str | None:
82
+ """Pull the agent-config subcommand out of a hooks.json command line."""
83
+ m = _CMD_RE.search(command_line)
84
+ if m:
85
+ return m.group(1)
86
+ return None
87
+
88
+
89
+ def load_dispatcher_subcommands(dispatch_path: Path) -> set[str]:
90
+ """Return the set of subcommand identifiers the dispatcher knows.
91
+
92
+ Reads `cmd_<name>` function definitions from _dispatch.bash and
93
+ converts back to subcommand form (underscores → colons / hyphens
94
+ is ambiguous, so we keep BOTH forms in the set — `dispatch_hook`
95
+ AND `dispatch:hook` — so the linter accepts either).
96
+ """
97
+ try:
98
+ text = dispatch_path.read_text(encoding="utf-8")
99
+ except OSError as exc:
100
+ raise SystemExit(f"lint-marketplace-install: cannot read {dispatch_path}: {exc}")
101
+
102
+ out: set[str] = set()
103
+ for match in re.finditer(r"^cmd_([a-zA-Z0-9_]+)\(\)", text, flags=re.MULTILINE):
104
+ ident = match.group(1)
105
+ # Add the underscore form.
106
+ out.add(ident)
107
+ # Also add a colon-substituted variant — agent-config supports
108
+ # `:` in user-facing subcommand names; the function strips them
109
+ # to underscores. We accept either spelling on the hook side.
110
+ # First _ → `:`, the rest stay (heuristic; covers `dispatch:hook`,
111
+ # `mcp:render`, `hooks:install` etc.).
112
+ if "_" in ident:
113
+ head, _, tail = ident.partition("_")
114
+ out.add(f"{head}:{tail}")
115
+ return out
116
+
117
+
118
+ def lint(hooks_path: Path = HOOKS_JSON, dispatch_path: Path = DISPATCH_BASH) -> int:
119
+ if not hooks_path.is_file():
120
+ sys.stderr.write(f"lint-marketplace-install: {hooks_path} not found\n")
121
+ return 2
122
+ if not dispatch_path.is_file():
123
+ sys.stderr.write(f"lint-marketplace-install: {dispatch_path} not found\n")
124
+ return 2
125
+
126
+ commands = load_hook_commands(hooks_path)
127
+ known = load_dispatcher_subcommands(dispatch_path)
128
+
129
+ issues: list[str] = []
130
+ checked = 0
131
+ for event, cmd in commands:
132
+ sub = extract_subcommand(cmd)
133
+ if sub is None:
134
+ issues.append(
135
+ f" {event}: command does not reference `agent-config <subcommand>`: "
136
+ f"{cmd!r}"
137
+ )
138
+ continue
139
+ checked += 1
140
+ if sub not in known:
141
+ issues.append(
142
+ f" {event}: unknown_dispatcher_subcommand: {sub!r} "
143
+ f"(not in scripts/_dispatch.bash)"
144
+ )
145
+
146
+ if issues:
147
+ try:
148
+ relative = hooks_path.resolve().relative_to(REPO_ROOT)
149
+ except ValueError:
150
+ relative = hooks_path
151
+ sys.stderr.write(
152
+ f"lint-marketplace-install: {len(issues)} issue(s) in {relative}:\n"
153
+ )
154
+ for line in issues:
155
+ sys.stderr.write(line + "\n")
156
+ return 1
157
+
158
+ print(
159
+ f"✅ lint-marketplace-install: {checked} hook command(s) checked, "
160
+ f"all resolve to known dispatcher subcommands."
161
+ )
162
+ return 0
163
+
164
+
165
+ def parse_args(argv: list[str]) -> argparse.Namespace:
166
+ p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
167
+ p.add_argument(
168
+ "--hooks-json",
169
+ type=Path,
170
+ default=HOOKS_JSON,
171
+ help="Path to hooks/hooks.json (default: %(default)s)",
172
+ )
173
+ p.add_argument(
174
+ "--dispatch-bash",
175
+ type=Path,
176
+ default=DISPATCH_BASH,
177
+ help="Path to scripts/_dispatch.bash (default: %(default)s)",
178
+ )
179
+ return p.parse_args(argv)
180
+
181
+
182
+ def main(argv: list[str] | None = None) -> int:
183
+ args = parse_args(argv if argv is not None else sys.argv[1:])
184
+ return lint(args.hooks_json, args.dispatch_bash)
185
+
186
+
187
+ if __name__ == "__main__":
188
+ raise SystemExit(main())