@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,320 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Measure rtk's token savings on a fixed corpus of verbose CLI invocations.
|
|
3
|
+
|
|
4
|
+
Phase 2 Step 3 of `agents/roadmaps/road-to-readable-value-dashboard.md`.
|
|
5
|
+
|
|
6
|
+
For each entry in `internal/bench/corpora/rtk/commands.yaml`:
|
|
7
|
+
1. Run the raw command, capture stdout + stderr bytes.
|
|
8
|
+
2. Run the rtk-wrapped command, capture stdout + stderr bytes.
|
|
9
|
+
3. Compute char + token deltas (chars / 4 approximation).
|
|
10
|
+
4. Record per-command result + aggregate.
|
|
11
|
+
|
|
12
|
+
Output: `internal/bench/reports/rtk/<UTC>.json` + `latest.json`.
|
|
13
|
+
|
|
14
|
+
Each command runs in the repo root with a 30 s timeout. Missing tools
|
|
15
|
+
(`rtk` not installed, raw command not on PATH) emit `skipped: <reason>`
|
|
16
|
+
entries and are excluded from the aggregate. The script never crashes —
|
|
17
|
+
mirror the placeholder discipline of `render_benchmark_md.py`.
|
|
18
|
+
|
|
19
|
+
Surfaces honoured per `script-writing`:
|
|
20
|
+
--quiet suppress per-step progress (errors still print to stderr)
|
|
21
|
+
--corpus override the default corpus path
|
|
22
|
+
--out override the default report dir
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import shutil
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Dict, List
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import yaml
|
|
37
|
+
except ImportError:
|
|
38
|
+
yaml = None # type: ignore[assignment]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
42
|
+
DEFAULT_CORPUS = REPO_ROOT / "internal" / "bench" / "corpora" / "rtk" / "commands.yaml"
|
|
43
|
+
DEFAULT_OUT_DIR = REPO_ROOT / "internal" / "bench" / "reports" / "rtk"
|
|
44
|
+
TIMEOUT_SECONDS = 30
|
|
45
|
+
CHARS_PER_TOKEN = 4
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _utc_iso() -> str:
|
|
49
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _log(msg: str, quiet: bool, *, err: bool = False) -> None:
|
|
53
|
+
if err:
|
|
54
|
+
print(msg, file=sys.stderr)
|
|
55
|
+
return
|
|
56
|
+
if not quiet:
|
|
57
|
+
print(msg)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_capture(argv: List[str], cwd: Path) -> Dict[str, Any]:
|
|
61
|
+
"""Run a command, return stdout+stderr bytes + exit code.
|
|
62
|
+
|
|
63
|
+
Never raises — TimeoutExpired, FileNotFoundError, OSError each
|
|
64
|
+
produce a dict marker. Bench results explicitly carry failures so
|
|
65
|
+
the aggregate can exclude them.
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
argv,
|
|
70
|
+
cwd=str(cwd),
|
|
71
|
+
capture_output=True,
|
|
72
|
+
timeout=TIMEOUT_SECONDS,
|
|
73
|
+
check=False,
|
|
74
|
+
)
|
|
75
|
+
except FileNotFoundError as exc:
|
|
76
|
+
return {
|
|
77
|
+
"error": f"FileNotFoundError: {exc}",
|
|
78
|
+
"stdout_bytes": 0,
|
|
79
|
+
"stderr_bytes": 0,
|
|
80
|
+
"chars": 0,
|
|
81
|
+
"tokens_approx": 0,
|
|
82
|
+
"returncode": None,
|
|
83
|
+
}
|
|
84
|
+
except subprocess.TimeoutExpired:
|
|
85
|
+
return {
|
|
86
|
+
"error": f"TimeoutExpired after {TIMEOUT_SECONDS}s",
|
|
87
|
+
"stdout_bytes": 0,
|
|
88
|
+
"stderr_bytes": 0,
|
|
89
|
+
"chars": 0,
|
|
90
|
+
"tokens_approx": 0,
|
|
91
|
+
"returncode": None,
|
|
92
|
+
}
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
return {
|
|
95
|
+
"error": f"OSError: {exc}",
|
|
96
|
+
"stdout_bytes": 0,
|
|
97
|
+
"stderr_bytes": 0,
|
|
98
|
+
"chars": 0,
|
|
99
|
+
"tokens_approx": 0,
|
|
100
|
+
"returncode": None,
|
|
101
|
+
}
|
|
102
|
+
stdout = result.stdout or b""
|
|
103
|
+
stderr = result.stderr or b""
|
|
104
|
+
chars = len(stdout) + len(stderr)
|
|
105
|
+
return {
|
|
106
|
+
"error": None,
|
|
107
|
+
"stdout_bytes": len(stdout),
|
|
108
|
+
"stderr_bytes": len(stderr),
|
|
109
|
+
"chars": chars,
|
|
110
|
+
"tokens_approx": chars // CHARS_PER_TOKEN,
|
|
111
|
+
"returncode": result.returncode,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def measure_one(entry: Dict[str, Any], cwd: Path, quiet: bool) -> Dict[str, Any]:
|
|
116
|
+
"""Measure one corpus entry."""
|
|
117
|
+
entry_id = entry["id"]
|
|
118
|
+
description = entry.get("description", "")
|
|
119
|
+
raw = entry["raw"]
|
|
120
|
+
rtk = entry["rtk"]
|
|
121
|
+
|
|
122
|
+
raw_cmd = raw[0] if raw else None
|
|
123
|
+
rtk_cmd = rtk[0] if rtk else None
|
|
124
|
+
|
|
125
|
+
if raw_cmd and not shutil.which(raw_cmd):
|
|
126
|
+
return {
|
|
127
|
+
"id": entry_id,
|
|
128
|
+
"description": description,
|
|
129
|
+
"skipped": f"raw command '{raw_cmd}' not on PATH",
|
|
130
|
+
"raw": None,
|
|
131
|
+
"rtk": None,
|
|
132
|
+
"delta": None,
|
|
133
|
+
}
|
|
134
|
+
if rtk_cmd and not shutil.which(rtk_cmd):
|
|
135
|
+
return {
|
|
136
|
+
"id": entry_id,
|
|
137
|
+
"description": description,
|
|
138
|
+
"skipped": f"rtk command '{rtk_cmd}' not on PATH",
|
|
139
|
+
"raw": None,
|
|
140
|
+
"rtk": None,
|
|
141
|
+
"delta": None,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
_log(f" {entry_id}: running raw …", quiet)
|
|
145
|
+
raw_result = _run_capture(raw, cwd)
|
|
146
|
+
_log(f" {entry_id}: running rtk …", quiet)
|
|
147
|
+
rtk_result = _run_capture(rtk, cwd)
|
|
148
|
+
|
|
149
|
+
if raw_result.get("error") or rtk_result.get("error"):
|
|
150
|
+
return {
|
|
151
|
+
"id": entry_id,
|
|
152
|
+
"description": description,
|
|
153
|
+
"skipped": (
|
|
154
|
+
f"raw error: {raw_result.get('error')}; "
|
|
155
|
+
f"rtk error: {rtk_result.get('error')}"
|
|
156
|
+
),
|
|
157
|
+
"raw": raw_result,
|
|
158
|
+
"rtk": rtk_result,
|
|
159
|
+
"delta": None,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
raw_chars = raw_result["chars"]
|
|
163
|
+
rtk_chars = rtk_result["chars"]
|
|
164
|
+
chars_saved = raw_chars - rtk_chars
|
|
165
|
+
tokens_saved = chars_saved // CHARS_PER_TOKEN
|
|
166
|
+
pct_saved = (
|
|
167
|
+
(chars_saved / raw_chars * 100.0) if raw_chars > 0 else 0.0
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"id": entry_id,
|
|
172
|
+
"description": description,
|
|
173
|
+
"skipped": None,
|
|
174
|
+
"raw": raw_result,
|
|
175
|
+
"rtk": rtk_result,
|
|
176
|
+
"delta": {
|
|
177
|
+
"chars_saved": chars_saved,
|
|
178
|
+
"tokens_saved": tokens_saved,
|
|
179
|
+
"pct_saved": round(pct_saved, 3),
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def aggregate(results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
185
|
+
"""Compute the aggregate block from per-command results."""
|
|
186
|
+
measured = [r for r in results if not r.get("skipped") and r.get("delta")]
|
|
187
|
+
if not measured:
|
|
188
|
+
return {
|
|
189
|
+
"commands_measured": 0,
|
|
190
|
+
"commands_skipped": len(results) - len(measured),
|
|
191
|
+
"total_chars_saved": 0,
|
|
192
|
+
"total_tokens_saved": 0,
|
|
193
|
+
"median_pct_saved": 0.0,
|
|
194
|
+
"tokens_saved_per_request": 0,
|
|
195
|
+
}
|
|
196
|
+
chars_saved_total = sum(r["delta"]["chars_saved"] for r in measured)
|
|
197
|
+
tokens_saved_total = sum(r["delta"]["tokens_saved"] for r in measured)
|
|
198
|
+
pcts = sorted(r["delta"]["pct_saved"] for r in measured)
|
|
199
|
+
median_pct = pcts[len(pcts) // 2]
|
|
200
|
+
# Per-request approximation: average tokens saved across the corpus.
|
|
201
|
+
# A real agent invocation typically pipes ONE such command into the
|
|
202
|
+
# context per request — so the per-request saving is the mean, not
|
|
203
|
+
# the sum, of the corpus.
|
|
204
|
+
per_request = tokens_saved_total // len(measured)
|
|
205
|
+
return {
|
|
206
|
+
"commands_measured": len(measured),
|
|
207
|
+
"commands_skipped": len(results) - len(measured),
|
|
208
|
+
"total_chars_saved": chars_saved_total,
|
|
209
|
+
"total_tokens_saved": tokens_saved_total,
|
|
210
|
+
"median_pct_saved": median_pct,
|
|
211
|
+
"tokens_saved_per_request": per_request,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def run(
|
|
216
|
+
corpus_path: Path = DEFAULT_CORPUS,
|
|
217
|
+
out_dir: Path = DEFAULT_OUT_DIR,
|
|
218
|
+
quiet: bool = False,
|
|
219
|
+
) -> int:
|
|
220
|
+
"""Run the bench, write the report, return 0 on success."""
|
|
221
|
+
if yaml is None:
|
|
222
|
+
_log("PyYAML is required to load the rtk corpus.", quiet, err=True)
|
|
223
|
+
return 1
|
|
224
|
+
if not corpus_path.exists():
|
|
225
|
+
_log(f"corpus not found: {corpus_path}", quiet, err=True)
|
|
226
|
+
return 1
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
corpus = yaml.safe_load(corpus_path.read_text()) or {}
|
|
230
|
+
except yaml.YAMLError as exc:
|
|
231
|
+
_log(f"failed to parse corpus YAML: {exc}", quiet, err=True)
|
|
232
|
+
return 1
|
|
233
|
+
|
|
234
|
+
entries = corpus.get("commands", []) or []
|
|
235
|
+
if not entries:
|
|
236
|
+
_log("corpus has no commands", quiet, err=True)
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
_log(f"rtk savings bench — {len(entries)} commands", quiet)
|
|
240
|
+
results = [measure_one(entry, REPO_ROOT, quiet) for entry in entries]
|
|
241
|
+
agg = aggregate(results)
|
|
242
|
+
|
|
243
|
+
report = {
|
|
244
|
+
"schema_version": 1,
|
|
245
|
+
"schema_id": "rtk-v1",
|
|
246
|
+
"generated_at": _utc_iso(),
|
|
247
|
+
"corpus": {
|
|
248
|
+
"id": corpus.get("corpus_id", "rtk-commands"),
|
|
249
|
+
"path": str(corpus_path.relative_to(REPO_ROOT)),
|
|
250
|
+
"command_count": len(entries),
|
|
251
|
+
},
|
|
252
|
+
"commands": results,
|
|
253
|
+
"aggregate": agg,
|
|
254
|
+
"notes": [
|
|
255
|
+
f"Tokens approximated at {CHARS_PER_TOKEN} chars / token.",
|
|
256
|
+
(
|
|
257
|
+
"tokens_saved_per_request is the per-command mean across "
|
|
258
|
+
"measured entries; assumes one CLI invocation per request."
|
|
259
|
+
),
|
|
260
|
+
(
|
|
261
|
+
"Skipped commands carry a 'skipped' reason and are excluded "
|
|
262
|
+
"from the aggregate."
|
|
263
|
+
),
|
|
264
|
+
],
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
stamp = report["generated_at"].replace(":", "-")
|
|
269
|
+
timestamped = out_dir / f"{stamp}.json"
|
|
270
|
+
latest = out_dir / "latest.json"
|
|
271
|
+
payload = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
|
|
272
|
+
timestamped.write_text(payload)
|
|
273
|
+
latest.write_text(payload)
|
|
274
|
+
|
|
275
|
+
_log(
|
|
276
|
+
(
|
|
277
|
+
f"rtk savings: {agg['commands_measured']}/{len(entries)} measured, "
|
|
278
|
+
f"median {agg['median_pct_saved']:.1f}% saved, "
|
|
279
|
+
f"{agg['tokens_saved_per_request']} tokens/request "
|
|
280
|
+
f"(report: {timestamped.relative_to(REPO_ROOT)})"
|
|
281
|
+
),
|
|
282
|
+
quiet=False, # always print the headline (one-line summary)
|
|
283
|
+
)
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def parse_args(argv: List[str]) -> argparse.Namespace:
|
|
288
|
+
parser = argparse.ArgumentParser(
|
|
289
|
+
description=(
|
|
290
|
+
"Measure rtk's token savings on a fixed corpus of verbose CLI "
|
|
291
|
+
"invocations."
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
parser.add_argument(
|
|
295
|
+
"--corpus",
|
|
296
|
+
type=Path,
|
|
297
|
+
default=DEFAULT_CORPUS,
|
|
298
|
+
help="Path to the corpus YAML (default: %(default)s)",
|
|
299
|
+
)
|
|
300
|
+
parser.add_argument(
|
|
301
|
+
"--out",
|
|
302
|
+
type=Path,
|
|
303
|
+
default=DEFAULT_OUT_DIR,
|
|
304
|
+
help="Output directory for reports (default: %(default)s)",
|
|
305
|
+
)
|
|
306
|
+
parser.add_argument(
|
|
307
|
+
"--quiet",
|
|
308
|
+
action="store_true",
|
|
309
|
+
help="Suppress per-step progress; print one-line summary only.",
|
|
310
|
+
)
|
|
311
|
+
return parser.parse_args(argv)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main(argv: List[str] | None = None) -> int:
|
|
315
|
+
args = parse_args(argv if argv is not None else sys.argv[1:])
|
|
316
|
+
return run(corpus_path=args.corpus, out_dir=args.out, quiet=args.quiet)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
if __name__ == "__main__":
|
|
320
|
+
raise SystemExit(main())
|
|
@@ -194,19 +194,33 @@ def build() -> dict:
|
|
|
194
194
|
}
|
|
195
195
|
|
|
196
196
|
|
|
197
|
+
PRETTY_PATH = OUT_PATH.with_suffix(".pretty.json")
|
|
198
|
+
|
|
199
|
+
|
|
197
200
|
def main(argv: list[str]) -> int:
|
|
198
201
|
out = build()
|
|
199
|
-
|
|
202
|
+
# Default: minified (Phase 2 of road-to-value-dashboard-netto-cuts).
|
|
203
|
+
# `--pretty` writes the human-readable variant ONLY (no minified).
|
|
204
|
+
# The Python consumers in this repo (`lint_rule_budget`,
|
|
205
|
+
# `check_router`) use `json.load()` and are format-agnostic.
|
|
206
|
+
pretty_text = json.dumps(out, indent=2, sort_keys=False) + "\n"
|
|
207
|
+
minified_text = json.dumps(out, separators=(",", ":"), sort_keys=False) + "\n"
|
|
208
|
+
text = pretty_text if "--pretty" in argv else minified_text
|
|
209
|
+
target_path = PRETTY_PATH if "--pretty" in argv else OUT_PATH
|
|
200
210
|
if "--check" in argv:
|
|
201
|
-
if not OUT_PATH.exists() or OUT_PATH.read_text(encoding="utf-8") !=
|
|
211
|
+
if not OUT_PATH.exists() or OUT_PATH.read_text(encoding="utf-8") != minified_text:
|
|
202
212
|
print("router.json out of date — run scripts/compile_router.py", file=sys.stderr)
|
|
203
213
|
return 1
|
|
204
214
|
print("✅ router.json is up to date")
|
|
205
215
|
return 0
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
217
|
+
target_path.write_text(text, encoding="utf-8")
|
|
208
218
|
counts = (len(out["kernel"]), len(out["tier_1"]), len(out["tier_2"]))
|
|
209
|
-
|
|
219
|
+
fmt = "pretty" if "--pretty" in argv else "minified"
|
|
220
|
+
print(
|
|
221
|
+
f"✅ {target_path.name} ({fmt}) — "
|
|
222
|
+
f"kernel={counts[0]} tier-1={counts[1]} tier-2={counts[2]}"
|
|
223
|
+
)
|
|
210
224
|
return 0
|
|
211
225
|
|
|
212
226
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema_version": "expected-perms/v1",
|
|
3
|
-
"$comment": "Expected POSIX modes for the global install tree. Consumed by scripts/lint_global_paths.py as
|
|
3
|
+
"$comment": "Expected POSIX modes for the global install tree. Consumed by scripts/lint_global_paths.py as a standalone perms audit (historically the entry-gate for the migrate-to-global subcommand; that command was collapsed into agent-config migrate — see docs/contracts/migrate-command.md). Octal modes are strings so JSON tooling never widens 0700 → 700.",
|
|
4
4
|
"global_root": {
|
|
5
5
|
"path": "~/.event4u/agent-config",
|
|
6
6
|
"expected_mode": "0700",
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""First-run gate — surface the marketplace-install-but-unscaffolded shape.
|
|
3
|
+
|
|
4
|
+
Phase 2 of `road-to-hooks-actually-fire-in-consumers`.
|
|
5
|
+
|
|
6
|
+
When a consumer enables the plugin via `/plugin install` but never
|
|
7
|
+
runs `agent-config init` (or `hooks:install --claude --regen`), the
|
|
8
|
+
hooks declared in `hooks/hooks.json` fire but cannot do anything —
|
|
9
|
+
their commands resolve through an `$CLAUDE_PROJECT_DIR/agent-config`
|
|
10
|
+
that does not exist, or call a regenerator script that lives only in
|
|
11
|
+
package source-checkouts. The user has no way to discover this.
|
|
12
|
+
|
|
13
|
+
This hook runs on `session_start` only. It detects the failure shape
|
|
14
|
+
and surfaces it two ways (Council R3 HIGH — stderr alone is invisible
|
|
15
|
+
to the average user):
|
|
16
|
+
|
|
17
|
+
1. One stderr line — Claude shows session-start hook stderr in its
|
|
18
|
+
lifecycle log; power users will see it there.
|
|
19
|
+
2. A file at `$CLAUDE_PROJECT_DIR/.augment/.first-run-action-needed.md`
|
|
20
|
+
that the user discovers on the next `ls` of their tree.
|
|
21
|
+
|
|
22
|
+
Setup-complete detector (Council R3 MEDIUM — prevents banner spam):
|
|
23
|
+
the hook exits early without writing if the checklist passes
|
|
24
|
+
(`./agent-config` symlink executable + `.augment/scripts/update_roadmap_progress.py`
|
|
25
|
+
exists). Once the user runs `hooks:install --claude --regen`, the
|
|
26
|
+
file written by a prior run gets cleaned up the next time this hook
|
|
27
|
+
runs successfully.
|
|
28
|
+
|
|
29
|
+
Contract: never blocks. Returns 0 on every path.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import sys
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
PLUGIN_ID = "agent-config@event4u-agent-config"
|
|
41
|
+
ACTION_NEEDED_FILE = ".augment/.first-run-action-needed.md"
|
|
42
|
+
|
|
43
|
+
REGENERATOR_PATHS = (
|
|
44
|
+
".augment/scripts/update_roadmap_progress.py",
|
|
45
|
+
".agent-src/scripts/update_roadmap_progress.py",
|
|
46
|
+
".agent-src.uncondensed/scripts/update_roadmap_progress.py",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
ACTION_NEEDED_BODY = """# First-run action needed — `agent-config` plugin
|
|
50
|
+
|
|
51
|
+
You enabled the `agent-config@event4u-agent-config` plugin via
|
|
52
|
+
`/plugin install`, but your project is missing the prerequisites
|
|
53
|
+
the plugin's hooks need to actually fire:
|
|
54
|
+
|
|
55
|
+
- `./agent-config` symlink at the repo root (needed by every hook).
|
|
56
|
+
- `.augment/scripts/update_roadmap_progress.py` (needed by the
|
|
57
|
+
roadmap-progress hook to regenerate the dashboard).
|
|
58
|
+
|
|
59
|
+
Fix in one command:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
./agent-config hooks:install --claude --regen
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or run the full installer:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
./agent-config init
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
After either command, this file deletes itself on the next session
|
|
72
|
+
start. If you don't want the plugin's hooks, disable it via
|
|
73
|
+
`/plugin disable agent-config@event4u-agent-config` and delete
|
|
74
|
+
this file manually.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _plugin_enabled(consumer_root: Path) -> bool:
|
|
79
|
+
"""Returns True iff `.claude/settings.json` has the plugin id under
|
|
80
|
+
`enabledPlugins` with a truthy value."""
|
|
81
|
+
settings = consumer_root / ".claude" / "settings.json"
|
|
82
|
+
if not settings.is_file():
|
|
83
|
+
return False
|
|
84
|
+
try:
|
|
85
|
+
data = json.loads(settings.read_text(encoding="utf-8"))
|
|
86
|
+
except (OSError, json.JSONDecodeError):
|
|
87
|
+
return False
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
return False
|
|
90
|
+
enabled = data.get("enabledPlugins")
|
|
91
|
+
if not isinstance(enabled, dict):
|
|
92
|
+
return False
|
|
93
|
+
return bool(enabled.get(PLUGIN_ID))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _agent_config_executable(consumer_root: Path) -> bool:
|
|
97
|
+
"""`./agent-config` exists AND is executable (whether file or symlink)."""
|
|
98
|
+
p = consumer_root / "agent-config"
|
|
99
|
+
if not p.exists():
|
|
100
|
+
return False
|
|
101
|
+
return os.access(p, os.X_OK)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _regenerator_present(consumer_root: Path) -> bool:
|
|
105
|
+
return any((consumer_root / rel).is_file() for rel in REGENERATOR_PATHS)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _setup_complete(consumer_root: Path) -> bool:
|
|
109
|
+
return _agent_config_executable(consumer_root) and _regenerator_present(consumer_root)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _write_action_file(consumer_root: Path) -> bool:
|
|
113
|
+
"""Best-effort write. Returns True on success."""
|
|
114
|
+
target = consumer_root / ACTION_NEEDED_FILE
|
|
115
|
+
try:
|
|
116
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
target.write_text(ACTION_NEEDED_BODY, encoding="utf-8")
|
|
118
|
+
return True
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
sys.stderr.write(
|
|
121
|
+
f"first-run-gate: could not write {target}: {exc}\n"
|
|
122
|
+
)
|
|
123
|
+
return False
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cleanup_action_file(consumer_root: Path) -> None:
|
|
127
|
+
"""Remove the action-needed file once setup is complete. Best-effort."""
|
|
128
|
+
target = consumer_root / ACTION_NEEDED_FILE
|
|
129
|
+
if target.exists():
|
|
130
|
+
try:
|
|
131
|
+
target.unlink()
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def run(consumer_root: Path) -> int:
|
|
137
|
+
if os.environ.get("AGENT_CONFIG_REPLAY") == "1":
|
|
138
|
+
# Fixture-driven replay must not mutate state.
|
|
139
|
+
return 0
|
|
140
|
+
if not _plugin_enabled(consumer_root):
|
|
141
|
+
# Plugin not enabled — nothing to gate on. Silent.
|
|
142
|
+
return 0
|
|
143
|
+
if _setup_complete(consumer_root):
|
|
144
|
+
# Setup checklist passes — clean up any stale action-needed file
|
|
145
|
+
# left by a prior run, then exit silently.
|
|
146
|
+
_cleanup_action_file(consumer_root)
|
|
147
|
+
return 0
|
|
148
|
+
|
|
149
|
+
# Failure shape detected. Two visible surfaces:
|
|
150
|
+
sys.stderr.write(
|
|
151
|
+
"first-run-gate: agent-config plugin is enabled but "
|
|
152
|
+
"scaffolding is missing — run `./agent-config hooks:install "
|
|
153
|
+
"--claude --regen` (details written to "
|
|
154
|
+
f"{ACTION_NEEDED_FILE})\n"
|
|
155
|
+
)
|
|
156
|
+
_write_action_file(consumer_root)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
161
|
+
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
162
|
+
p.add_argument("--platform", default="generic")
|
|
163
|
+
return p.parse_args(argv)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main(argv: list[str] | None = None) -> int:
|
|
167
|
+
_ = parse_args(argv if argv is not None else sys.argv[1:])
|
|
168
|
+
# Drain stdin envelope so the dispatcher pipe contract holds.
|
|
169
|
+
try:
|
|
170
|
+
sys.stdin.read()
|
|
171
|
+
except OSError:
|
|
172
|
+
pass
|
|
173
|
+
consumer_root = Path(os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd())
|
|
174
|
+
return run(consumer_root)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
raise SystemExit(main())
|
|
@@ -45,16 +45,25 @@ concerns:
|
|
|
45
45
|
script: scripts/minimal_safe_diff_hook.py
|
|
46
46
|
args: []
|
|
47
47
|
fail_closed: false
|
|
48
|
+
# Phase 2 of road-to-hooks-actually-fire-in-consumers — session_start
|
|
49
|
+
# gate that surfaces the marketplace-install-but-unscaffolded shape.
|
|
50
|
+
# Writes .augment/.first-run-action-needed.md + one stderr line so
|
|
51
|
+
# both file-browser users and lifecycle-log readers see the issue.
|
|
52
|
+
# Council R3 HIGH: stderr alone is invisible to the average user.
|
|
53
|
+
first-run-gate:
|
|
54
|
+
script: scripts/first_run_gate_hook.py
|
|
55
|
+
args: []
|
|
56
|
+
fail_closed: false
|
|
48
57
|
|
|
49
58
|
platforms:
|
|
50
59
|
augment:
|
|
51
|
-
session_start: [chat-history, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
60
|
+
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
52
61
|
session_end: [chat-history]
|
|
53
62
|
stop: [chat-history, verify-before-complete]
|
|
54
63
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff]
|
|
55
64
|
|
|
56
65
|
claude:
|
|
57
|
-
session_start: [chat-history, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
66
|
+
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
58
67
|
session_end: [chat-history]
|
|
59
68
|
stop: [chat-history, verify-before-complete]
|
|
60
69
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
@@ -75,7 +84,7 @@ platforms:
|
|
|
75
84
|
# Decision matrix + upstream blockers tracked in
|
|
76
85
|
# agents/settings/contexts/chat-history-platform-hooks.md § Cowork.
|
|
77
86
|
cowork:
|
|
78
|
-
session_start: [chat-history, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
87
|
+
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
79
88
|
session_end: [chat-history]
|
|
80
89
|
stop: [chat-history, verify-before-complete]
|
|
81
90
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
@@ -89,7 +98,7 @@ platforms:
|
|
|
89
98
|
# IDE-only — CLI-only users fall back to /checkpoint per
|
|
90
99
|
# agents/settings/contexts/chat-history-platform-hooks.md.
|
|
91
100
|
cursor:
|
|
92
|
-
session_start: [chat-history, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
101
|
+
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
93
102
|
session_end: [chat-history]
|
|
94
103
|
stop: [chat-history, verify-before-complete]
|
|
95
104
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
@@ -104,7 +113,7 @@ platforms:
|
|
|
104
113
|
# both map to session_start. TaskCancel maps to stop because the
|
|
105
114
|
# session is interrupted with partial state (mirrors Augment Stop).
|
|
106
115
|
cline:
|
|
107
|
-
session_start: [chat-history, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
116
|
+
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
108
117
|
session_end: [chat-history]
|
|
109
118
|
stop: [chat-history, verify-before-complete]
|
|
110
119
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
@@ -123,7 +132,7 @@ platforms:
|
|
|
123
132
|
# surface to record verification commands; documented limitation).
|
|
124
133
|
# minimal-safe-diff is omitted entirely on Windsurf for the same reason.
|
|
125
134
|
windsurf:
|
|
126
|
-
session_start: [chat-history, onboarding-gate, verify-before-complete]
|
|
135
|
+
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete]
|
|
127
136
|
stop: [chat-history, verify-before-complete]
|
|
128
137
|
user_prompt_submit: [chat-history, verify-before-complete]
|
|
129
138
|
|
|
@@ -138,7 +147,7 @@ platforms:
|
|
|
138
147
|
# turn-check semantics. AfterAgent fires when the agent loop ends
|
|
139
148
|
# — this is our `stop` slot.
|
|
140
149
|
gemini:
|
|
141
|
-
session_start: [chat-history, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
150
|
+
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff]
|
|
142
151
|
session_end: [chat-history]
|
|
143
152
|
stop: [chat-history, verify-before-complete]
|
|
144
153
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
@@ -38,6 +38,7 @@ MANIFEST_PATH = REPO_ROOT / "scripts" / "hook_manifest.yaml"
|
|
|
38
38
|
# hooks package state_io has changed (test isolation).
|
|
39
39
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
40
40
|
from state_io import atomic_write_json, feedback_dir, is_replay_mode # noqa: E402
|
|
41
|
+
from dispatch_issues import log_dispatch_issue, fix_hint # noqa: E402
|
|
41
42
|
|
|
42
43
|
EXIT_ALLOW = 0
|
|
43
44
|
EXIT_BLOCK = 1
|
|
@@ -234,6 +235,23 @@ def _run_concern(concern: dict, envelope: dict) -> tuple[int, str, str, int]:
|
|
|
234
235
|
cmd = [sys.executable, str(script), *(concern.get("args") or [])]
|
|
235
236
|
cmd.extend(["--platform", envelope.get("platform", "generic")])
|
|
236
237
|
workspace = envelope.get("workspace_root") or str(Path.cwd())
|
|
238
|
+
|
|
239
|
+
# Phase 1 of road-to-hooks-actually-fire-in-consumers: surface
|
|
240
|
+
# script-not-found via dispatch-issues.jsonl rather than silently
|
|
241
|
+
# consuming the OSError.
|
|
242
|
+
if not script.exists():
|
|
243
|
+
log_dispatch_issue(
|
|
244
|
+
workspace_root=Path(workspace),
|
|
245
|
+
hook=str(concern.get("name") or concern.get("script") or "unknown"),
|
|
246
|
+
issue="script_not_found",
|
|
247
|
+
detail=f"concern script missing on disk: {script}",
|
|
248
|
+
resolution=fix_hint(),
|
|
249
|
+
)
|
|
250
|
+
# Still return as if the concern failed — fail-open behaviour
|
|
251
|
+
# depends on the concern's `fail_closed` flag, which the
|
|
252
|
+
# dispatcher handles downstream.
|
|
253
|
+
return (3, f"{concern.get('name')}: script missing: {script}", "", 0)
|
|
254
|
+
|
|
237
255
|
started = time.monotonic()
|
|
238
256
|
try:
|
|
239
257
|
proc = subprocess.run(
|
|
@@ -247,6 +265,15 @@ def _run_concern(concern: dict, envelope: dict) -> tuple[int, str, str, int]:
|
|
|
247
265
|
)
|
|
248
266
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
249
267
|
elapsed = int((time.monotonic() - started) * 1000)
|
|
268
|
+
# Phase 1: also log execution-failed (subprocess errors) so the
|
|
269
|
+
# never-block contract keeps a trace.
|
|
270
|
+
log_dispatch_issue(
|
|
271
|
+
workspace_root=Path(workspace),
|
|
272
|
+
hook=str(concern.get("name") or "unknown"),
|
|
273
|
+
issue="execution_failed",
|
|
274
|
+
detail=f"{type(exc).__name__}: {exc}",
|
|
275
|
+
resolution=fix_hint(),
|
|
276
|
+
)
|
|
250
277
|
return (3, f"{concern.get('name')}: {exc}", "", elapsed)
|
|
251
278
|
elapsed = int((time.monotonic() - started) * 1000)
|
|
252
279
|
return (proc.returncode, proc.stderr or "", proc.stdout or "", elapsed)
|