@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.
- package/.agent-src/commands/implement-ticket.md +5 -4
- package/.agent-src/rules/language-and-tone.md +4 -10
- package/.agent-src/skills/command-routing/SKILL.md +5 -4
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +73 -0
- package/CONTRIBUTING.md +19 -0
- package/README.md +11 -0
- package/dist/cli/registry.js +0 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/router.json +1 -1671
- package/docs/benchmark.md +20 -8
- package/docs/benchmarks.md +11 -0
- package/docs/contracts/benchmark-corpus-spec.md +31 -3
- package/docs/contracts/command-surface-tiers.md +1 -1
- package/docs/contracts/hook-architecture-v1.md +33 -0
- package/docs/contracts/migrate-command.md +197 -0
- package/docs/contracts/settings-api.md +2 -1
- package/docs/contracts/value-dashboard-spec.md +374 -0
- package/docs/contracts/value-report-schema.md +150 -0
- package/docs/decisions/ADR-031-validation-severity-tiers-and-projection-roundtrip.md +97 -0
- package/docs/decisions/INDEX.md +1 -0
- package/docs/guidelines/agent-infra/installed-tools-manifest.md +6 -3
- package/docs/guidelines/agent-infra/language-and-tone-examples.md +35 -0
- package/docs/migration/v1-to-v2.md +40 -27
- package/docs/value.md +84 -0
- package/package.json +8 -8
- package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
- package/scripts/_cli/cmd_migrate.py +264 -102
- package/scripts/_cli/cmd_settings_migrate.py +2 -1
- package/scripts/_dispatch.bash +147 -49
- package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
- package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
- package/scripts/_lib/install_regenerator.py +129 -0
- package/scripts/_lib/value_ladder.py +599 -0
- package/scripts/_lib/value_report.py +441 -0
- package/scripts/bench_rtk_savings.py +320 -0
- package/scripts/compile_router.py +19 -5
- package/scripts/expected_perms.json +1 -1
- package/scripts/first_run_gate_hook.py +178 -0
- package/scripts/hook_manifest.yaml +16 -7
- package/scripts/hooks/dispatch_hook.py +27 -0
- package/scripts/hooks/dispatch_issues.py +136 -0
- package/scripts/hooks_doctor.py +40 -1
- package/scripts/install.py +25 -21
- package/scripts/lint_agents_layout.py +5 -4
- package/scripts/lint_bench_corpus.py +86 -4
- package/scripts/lint_global_paths.py +4 -3
- package/scripts/lint_marketplace_install_completeness.py +188 -0
- package/scripts/lint_value_dashboard.py +218 -0
- package/scripts/render_benchmark_md.py +6 -2
- package/scripts/render_value_md.py +355 -0
- package/scripts/repro/repro_marketplace_install_gap.sh +161 -0
- package/scripts/roadmap_progress_hook.py +23 -0
- package/scripts/router_telemetry.py +470 -0
- package/scripts/validate_frontmatter.py +23 -9
- 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"
|
package/scripts/hooks_doctor.py
CHANGED
|
@@ -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] = [
|
|
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
|
|
package/scripts/install.py
CHANGED
|
@@ -3009,13 +3009,14 @@ CONSUMER_BRIDGE_MARKER_RELPATH = Path("agents") / ".event4u-bridge.yml"
|
|
|
3009
3009
|
|
|
3010
3010
|
|
|
3011
3011
|
# ---------------------------------------------------------------------------
|
|
3012
|
-
#
|
|
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
|
|
3017
|
-
# ``scripts/_cli/
|
|
3018
|
-
# the prompt and the migration tool agree on
|
|
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
|
|
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
|
|
3132
|
-
|
|
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("
|
|
3140
|
-
info("
|
|
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
|
|
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 ``
|
|
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
|
|
3167
|
-
|
|
3168
|
-
|
|
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.
|
|
3177
|
+
cmd_mod = importlib.import_module("scripts._cli.cmd_migrate")
|
|
3174
3178
|
except ImportError as exc:
|
|
3175
|
-
warn(f"migrate
|
|
3179
|
+
warn(f"migrate unavailable: {exc}")
|
|
3176
3180
|
return 1
|
|
3177
3181
|
|
|
3178
|
-
|
|
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
|
-
#
|
|
4980
|
-
#
|
|
4981
|
-
#
|
|
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
|
|
76
|
-
"
|
|
77
|
-
"
|
|
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
|
|
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)
|
|
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
|
|
7
|
-
|
|
8
|
-
|
|
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())
|