@event4u/agent-config 4.9.0 → 5.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.agent-src/commands/implement-ticket.md +5 -4
  2. package/.agent-src/contexts/execution/roadmap-process-loop.md +30 -4
  3. package/.agent-src/rules/language-and-tone.md +4 -10
  4. package/.agent-src/rules/linked-projects-onboarding-gate.md +82 -0
  5. package/.agent-src/rules/roadmap-progress-sync.md +39 -5
  6. package/.agent-src/scripts/update_roadmap_progress.py +63 -7
  7. package/.agent-src/skills/command-routing/SKILL.md +5 -4
  8. package/.agent-src/skills/roadmap-management/SKILL.md +121 -21
  9. package/.agent-src/skills/roadmap-writing/SKILL.md +63 -0
  10. package/.agent-src/templates/agent-settings.md +16 -0
  11. package/.agent-src/templates/roadmaps.md +22 -1
  12. package/.agent-src/templates/scripts/work_engine/_lib/agent_settings.py +20 -3
  13. package/.claude-plugin/marketplace.json +1 -1
  14. package/CHANGELOG.md +106 -0
  15. package/CONTRIBUTING.md +19 -0
  16. package/README.md +12 -1
  17. package/dist/cli/registry.js +0 -2
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/discovery/deprecation-report.md +1 -1
  20. package/dist/discovery/discovery-manifest.json +36 -14
  21. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  22. package/dist/discovery/discovery-manifest.summary.md +3 -3
  23. package/dist/discovery/orphan-report.md +1 -1
  24. package/dist/discovery/packs.json +6 -5
  25. package/dist/discovery/trust-report.md +3 -3
  26. package/dist/discovery/workspaces.json +5 -4
  27. package/dist/mcp/registry-manifest.json +3 -3
  28. package/dist/router.json +1 -1671
  29. package/docs/architecture.md +1 -1
  30. package/docs/benchmark.md +20 -8
  31. package/docs/benchmarks.md +11 -0
  32. package/docs/catalog.md +3 -2
  33. package/docs/contracts/benchmark-corpus-spec.md +31 -3
  34. package/docs/contracts/command-surface-tiers.md +1 -1
  35. package/docs/contracts/hook-architecture-v1.md +33 -0
  36. package/docs/contracts/migrate-command.md +197 -0
  37. package/docs/contracts/settings-api.md +2 -1
  38. package/docs/contracts/value-dashboard-spec.md +374 -0
  39. package/docs/contracts/value-report-schema.md +150 -0
  40. package/docs/decisions/ADR-031-validation-severity-tiers-and-projection-roundtrip.md +97 -0
  41. package/docs/decisions/ADR-032-linked-projects-scope.md +118 -0
  42. package/docs/decisions/INDEX.md +2 -0
  43. package/docs/getting-started.md +1 -1
  44. package/docs/guidelines/agent-infra/installed-tools-manifest.md +6 -3
  45. package/docs/guidelines/agent-infra/language-and-tone-examples.md +35 -0
  46. package/docs/guides/cross-repo-linked-projects.md +86 -0
  47. package/docs/migration/v1-to-v2.md +40 -27
  48. package/docs/value.md +84 -0
  49. package/package.json +8 -8
  50. package/scripts/__pycache__/validate_frontmatter.cpython-312.pyc +0 -0
  51. package/scripts/_cli/cmd_migrate.py +264 -102
  52. package/scripts/_cli/cmd_settings_migrate.py +2 -1
  53. package/scripts/_dispatch.bash +147 -49
  54. package/scripts/_lib/__pycache__/__init__.cpython-312.pyc +0 -0
  55. package/scripts/_lib/__pycache__/agent_src.cpython-312.pyc +0 -0
  56. package/scripts/_lib/agent_settings.py +20 -3
  57. package/scripts/_lib/install_regenerator.py +129 -0
  58. package/scripts/_lib/linked_projects.py +238 -0
  59. package/scripts/_lib/value_ladder.py +599 -0
  60. package/scripts/_lib/value_report.py +441 -0
  61. package/scripts/bench_rtk_savings.py +320 -0
  62. package/scripts/check_no_local_settings_committed.py +51 -0
  63. package/scripts/compile_router.py +19 -5
  64. package/scripts/expected_perms.json +1 -1
  65. package/scripts/first_run_gate_hook.py +178 -0
  66. package/scripts/hook_manifest.yaml +16 -7
  67. package/scripts/hooks/dispatch_hook.py +27 -0
  68. package/scripts/hooks/dispatch_issues.py +136 -0
  69. package/scripts/hooks_doctor.py +40 -1
  70. package/scripts/install.py +25 -21
  71. package/scripts/lint_agents_layout.py +5 -4
  72. package/scripts/lint_bench_corpus.py +86 -4
  73. package/scripts/lint_global_paths.py +4 -3
  74. package/scripts/lint_marketplace_install_completeness.py +188 -0
  75. package/scripts/lint_value_dashboard.py +218 -0
  76. package/scripts/render_benchmark_md.py +6 -2
  77. package/scripts/render_value_md.py +355 -0
  78. package/scripts/repro/repro_marketplace_install_gap.sh +161 -0
  79. package/scripts/roadmap_progress_hook.py +23 -0
  80. package/scripts/router_telemetry.py +470 -0
  81. package/scripts/validate_frontmatter.py +23 -9
  82. 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())
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Fail if any ``.agent-settings.local.yml`` is tracked by git.
3
+
4
+ `.agent-settings.local.yml` is the per-developer, per-machine override layer
5
+ (see ``scripts/_lib/agent_settings.py`` ``LOCAL_PROJECT_FILE``). It is
6
+ gitignored on purpose — committing one would leak one developer's local
7
+ machine paths (e.g. linked-project siblings) into everyone's checkout.
8
+
9
+ Exit 0 when none are tracked, 1 (with the offending paths) otherwise.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import sys
16
+
17
+ LOCAL_FILE = ".agent-settings.local.yml"
18
+
19
+
20
+ def tracked_local_settings() -> list[str]:
21
+ try:
22
+ out = subprocess.run(
23
+ ["git", "ls-files"],
24
+ check=True,
25
+ capture_output=True,
26
+ text=True,
27
+ ).stdout
28
+ except (subprocess.CalledProcessError, FileNotFoundError):
29
+ # Not a git repo / git missing — nothing to enforce here.
30
+ return []
31
+ return [
32
+ line
33
+ for line in out.splitlines()
34
+ if line.split("/")[-1] == LOCAL_FILE
35
+ ]
36
+
37
+
38
+ def main() -> int:
39
+ offenders = tracked_local_settings()
40
+ if not offenders:
41
+ print(f"✅ No tracked {LOCAL_FILE} files.")
42
+ return 0
43
+ print(f"❌ {LOCAL_FILE} must never be committed (per-machine local layer):")
44
+ for path in offenders:
45
+ print(f" 🔴 {path}")
46
+ print(f"\nRun: git rm --cached <path> — and confirm {LOCAL_FILE} is gitignored.")
47
+ return 1
48
+
49
+
50
+ if __name__ == "__main__":
51
+ sys.exit(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
- text = json.dumps(out, indent=2, sort_keys=False) + "\n"
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") != text:
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
- OUT_PATH.parent.mkdir(parents=True, exist_ok=True)
207
- OUT_PATH.write_text(text, encoding="utf-8")
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
- print(f"✅ router.json kernel={counts[0]} tier-1={counts[1]} tier-2={counts[2]}")
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 an entry-gate for the migrate-to-global subcommand (road-to-global-only-install Phase 5.0 / A7). Octal modes are strings so JSON tooling never widens 0700 → 700.",
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]