@event4u/agent-config 2.9.0 → 2.10.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.
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """Fixture-driven hook replay — read-only dispatch through the runtime.
3
+
4
+ Reads a stdin payload fixture from `tests/fixtures/hooks/` (one file per
5
+ event in `EVENT_VOCABULARY`), sets `AGENT_CONFIG_REPLAY=1`, and invokes
6
+ the universal dispatcher with the platform / event / payload tuple. The
7
+ replay flag tells `state_io` (and concerns that honour it) to skip every
8
+ write under `agents/state/` so the replay never mutates real session
9
+ state.
10
+
11
+ Invocation:
12
+
13
+ python3 scripts/hooks/replay_hook.py \\
14
+ --platform <name> \\
15
+ --event <agent-config-event> \\
16
+ --payload tests/fixtures/hooks/<event>.json \\
17
+ [--native-event <native>] \\
18
+ [--manifest <path>] \\
19
+ [--json]
20
+
21
+ The `--json` flag prints a structured replay summary on stdout
22
+ (platform, event, dispatcher exit code, captured stderr lines).
23
+ Non-zero exit is propagated from the dispatcher.
24
+
25
+ Contract reference: `docs/contracts/hook-architecture-v1.md` § Replay
26
+ mode. Roadmap step: P2.4b of `agents/roadmaps/road-to-proof-not-features.md`.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import json
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ REPO_ROOT = Path(__file__).resolve().parents[2]
38
+ DISPATCHER = REPO_ROOT / "scripts" / "hooks" / "dispatch_hook.py"
39
+ DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "hook_manifest.yaml"
40
+ FIXTURE_DIR = REPO_ROOT / "tests" / "fixtures" / "hooks"
41
+ REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
42
+
43
+
44
+ def _resolve_payload(arg: str) -> Path:
45
+ """Accept either an absolute path, a path relative to CWD, or a bare
46
+ event name that resolves to `tests/fixtures/hooks/<name>.json`."""
47
+ candidate = Path(arg)
48
+ if candidate.is_file():
49
+ return candidate
50
+ bare = FIXTURE_DIR / f"{arg}.json"
51
+ if bare.is_file():
52
+ return bare
53
+ raise FileNotFoundError(
54
+ f"replay_hook: payload not found — tried '{candidate}' and '{bare}'")
55
+
56
+
57
+ def _build_argparser() -> argparse.ArgumentParser:
58
+ p = argparse.ArgumentParser(description=__doc__)
59
+ p.add_argument("--platform", required=True,
60
+ help="Platform key as declared in hook_manifest.yaml "
61
+ "(augment, claude, cursor, cline, windsurf, gemini, copilot).")
62
+ p.add_argument("--event", required=True,
63
+ help="agent-config event (see EVENT_VOCABULARY in "
64
+ "scripts/hooks/dispatch_hook.py).")
65
+ p.add_argument("--payload", required=True,
66
+ help="Path to a fixture JSON file, or a bare event name "
67
+ "resolved under tests/fixtures/hooks/.")
68
+ p.add_argument("--native-event", default="",
69
+ help="Optional native event name for diagnostics.")
70
+ p.add_argument("--manifest", default=str(DEFAULT_MANIFEST),
71
+ help=f"Hook manifest path (default: {DEFAULT_MANIFEST}).")
72
+ p.add_argument("--json", action="store_true",
73
+ help="Emit a structured summary on stdout.")
74
+ p.add_argument("--dry-run", action="store_true",
75
+ help="Resolve concerns and print the dispatch plan; "
76
+ "do not invoke concerns.")
77
+ return p
78
+
79
+
80
+ def main(argv: list[str] | None = None) -> int:
81
+ args = _build_argparser().parse_args(argv)
82
+
83
+ try:
84
+ payload_path = _resolve_payload(args.payload)
85
+ except FileNotFoundError as exc:
86
+ sys.stderr.write(f"❌ {exc}\n")
87
+ return 2
88
+
89
+ payload_text = payload_path.read_text(encoding="utf-8")
90
+ # Validate JSON early so dispatcher stderr stays focused on real
91
+ # concern problems. Empty / non-object payloads are still dispatched
92
+ # — that mirrors the platform contract (stdin can be empty).
93
+ try:
94
+ decoded = json.loads(payload_text) if payload_text.strip() else {}
95
+ except (ValueError, TypeError) as exc:
96
+ sys.stderr.write(f"❌ replay_hook: invalid JSON in {payload_path}: {exc}\n")
97
+ return 2
98
+
99
+ env = dict(os.environ)
100
+ env[REPLAY_ENV_VAR] = "1"
101
+
102
+ cmd = [sys.executable, str(DISPATCHER),
103
+ "--platform", args.platform,
104
+ "--event", args.event,
105
+ "--manifest", args.manifest]
106
+ if args.native_event:
107
+ cmd.extend(["--native-event", args.native_event])
108
+ if args.dry_run:
109
+ cmd.append("--dry-run")
110
+
111
+ proc = subprocess.run(
112
+ cmd, input=payload_text, capture_output=True, text=True, env=env,
113
+ check=False,
114
+ )
115
+
116
+ if args.json:
117
+ summary = {
118
+ "platform": args.platform,
119
+ "event": args.event,
120
+ "native_event": args.native_event or "",
121
+ "payload": str(payload_path.relative_to(REPO_ROOT)
122
+ if str(payload_path).startswith(str(REPO_ROOT))
123
+ else payload_path),
124
+ "session_id": decoded.get("session_id") if isinstance(decoded, dict) else None,
125
+ "exit_code": proc.returncode,
126
+ "dispatcher_stdout": (proc.stdout or "").strip(),
127
+ "dispatcher_stderr": (proc.stderr or "").strip(),
128
+ "replay_mode": True,
129
+ }
130
+ print(json.dumps(summary, indent=2))
131
+ else:
132
+ if proc.stdout:
133
+ sys.stdout.write(proc.stdout)
134
+ if proc.stderr:
135
+ sys.stderr.write(proc.stderr)
136
+ sys.stderr.write(
137
+ f"replay_hook: platform={args.platform} event={args.event} "
138
+ f"payload={payload_path.name} rc={proc.returncode} "
139
+ f"(AGENT_CONFIG_REPLAY=1, no writes)\n")
140
+ return proc.returncode
141
+
142
+
143
+ if __name__ == "__main__": # pragma: no cover
144
+ sys.exit(main())
@@ -40,6 +40,18 @@ except ImportError: # pragma: no cover — Windows
40
40
 
41
41
  LOCK_BASENAME = ".dispatcher.lock"
42
42
 
43
+ REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
44
+
45
+
46
+ def is_replay_mode() -> bool:
47
+ """True when the caller signalled read-only fixture replay.
48
+
49
+ Concerns and the dispatcher honour the flag by skipping side
50
+ effects under `agents/state/` (and any other concern-owned state
51
+ surface). See `docs/contracts/hook-architecture-v1.md` § Replay mode.
52
+ """
53
+ return os.environ.get(REPLAY_ENV_VAR, "").strip() == "1"
54
+
43
55
 
44
56
  def _lock_path(state_dir: Path) -> Path:
45
57
  return state_dir / LOCK_BASENAME
@@ -52,7 +64,12 @@ def atomic_write_json(target: Path, payload: Any, *, indent: int = 2) -> None:
52
64
  directory the caller treats as the lock scope). The lock file is
53
65
  `<target.parent>/.dispatcher.lock`. Caller does not need to create
54
66
  the directory in advance — this function ensures it.
67
+
68
+ Under `AGENT_CONFIG_REPLAY=1` the call is a no-op so fixture
69
+ replay never mutates real session state.
55
70
  """
71
+ if is_replay_mode():
72
+ return
56
73
  target = Path(target)
57
74
  state_dir = target.parent
58
75
  state_dir.mkdir(parents=True, exist_ok=True)
@@ -63,7 +80,11 @@ def atomic_write_json(target: Path, payload: Any, *, indent: int = 2) -> None:
63
80
  def atomic_write_text(target: Path, text: str) -> None:
64
81
  """Write text to `target` atomically and concurrency-safely. Same
65
82
  locking discipline as `atomic_write_json` — useful for non-JSON
66
- state payloads (chat-history transcript, status text)."""
83
+ state payloads (chat-history transcript, status text).
84
+
85
+ Under `AGENT_CONFIG_REPLAY=1` the call is a no-op."""
86
+ if is_replay_mode():
87
+ return
67
88
  target = Path(target)
68
89
  state_dir = target.parent
69
90
  state_dir.mkdir(parents=True, exist_ok=True)
@@ -117,6 +138,8 @@ __all__ = [
117
138
  "atomic_write_json",
118
139
  "atomic_write_text",
119
140
  "feedback_dir",
141
+ "is_replay_mode",
120
142
  "LOCK_BASENAME",
121
143
  "FEEDBACK_DIRNAME",
144
+ "REPLAY_ENV_VAR",
122
145
  ]
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env python3
2
+ """Hook doctor — read-only diagnostic over the hook runtime.
3
+
4
+ Wraps `scripts/hooks_status.py` (bridge presence + manifest bindings)
5
+ and adds three diagnostics the bare status table does not surface:
6
+
7
+ * **Concerns** — every concern declared in the manifest, its
8
+ `fail_closed` posture, the on-disk script path, and a one-line
9
+ file-exists check.
10
+ * **Trampolines** — per-platform shell trampoline expected under
11
+ `scripts/hooks/<platform>-dispatcher.sh`; flags any platform that
12
+ has manifest bindings but no trampoline on disk.
13
+ * **Last feedback** — for each concern, the most-recent dispatcher
14
+ feedback file under `agents/state/.dispatcher/*/<concern>.json`,
15
+ plus the per-rule state file under `agents/state/<concern>.json`
16
+ when one exists.
17
+
18
+ This is a **read-only** report. It never installs, modifies, or runs
19
+ anything — same contract as `hooks_status.py`. CI uses `--strict` to
20
+ turn missing bindings / trampolines into a non-zero exit.
21
+
22
+ Schema: docs/contracts/hook-architecture-v1.md.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import argparse
27
+ import json
28
+ import sys
29
+ from pathlib import Path
30
+
31
+ REPO_ROOT = Path(__file__).resolve().parents[1]
32
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
33
+ sys.path.insert(0, str(REPO_ROOT / "scripts" / "hooks"))
34
+
35
+ import dispatch_hook # noqa: E402
36
+ import hooks_status # noqa: E402
37
+
38
+ TRAMPOLINE_DIR = REPO_ROOT / "scripts" / "hooks"
39
+ STATE_DIR_DEFAULT = "agents/state"
40
+
41
+ # Platforms whose bridge file (settings.json) invokes the universal
42
+ # dispatcher directly — no shell trampoline required. Excluded from the
43
+ # "missing trampoline" check.
44
+ NATIVE_DISPATCH_PLATFORMS = frozenset({"claude"})
45
+
46
+
47
+ def _trampoline_for(platform: str) -> Path:
48
+ return TRAMPOLINE_DIR / f"{platform}-dispatcher.sh"
49
+
50
+
51
+ def _concern_state_file(state_dir: Path, concern: str) -> Path | None:
52
+ target = state_dir / f"{concern}.json"
53
+ return target if target.is_file() else None
54
+
55
+
56
+ def _latest_feedback(state_dir: Path, concern: str) -> Path | None:
57
+ """Return the most-recent dispatcher feedback file for the concern,
58
+ walking `agents/state/.dispatcher/<session>/<concern>.json`."""
59
+ dispatcher_dir = state_dir / ".dispatcher"
60
+ if not dispatcher_dir.is_dir():
61
+ return None
62
+ candidates = sorted(
63
+ dispatcher_dir.glob(f"*/{concern}.json"),
64
+ key=lambda p: p.stat().st_mtime,
65
+ reverse=True,
66
+ )
67
+ return candidates[0] if candidates else None
68
+
69
+
70
+ def _rel(path: Path | None, root: Path) -> str | None:
71
+ if path is None:
72
+ return None
73
+ try:
74
+ return str(path.resolve().relative_to(root.resolve()))
75
+ except ValueError:
76
+ return str(path)
77
+
78
+
79
+ def collect(project_root: Path, manifest: dict,
80
+ state_dir_rel: str = STATE_DIR_DEFAULT) -> dict:
81
+ """Build the doctor payload — JSON-serialisable."""
82
+ matrix = hooks_status.collect(project_root, manifest)
83
+ state_dir = project_root / state_dir_rel
84
+
85
+ concerns_def = manifest.get("concerns") or {}
86
+ concerns: list[dict] = []
87
+ for name, spec in sorted(concerns_def.items()):
88
+ script_rel = (spec or {}).get("script") or ""
89
+ script_path = REPO_ROOT / script_rel if script_rel else None
90
+ state_file = _concern_state_file(state_dir, name)
91
+ last_feedback = _latest_feedback(state_dir, name)
92
+ concerns.append({
93
+ "concern": name,
94
+ "fail_closed": bool((spec or {}).get("fail_closed", False)),
95
+ "script": script_rel or None,
96
+ "script_present": bool(script_path and script_path.is_file()),
97
+ "state_file": _rel(state_file, project_root),
98
+ "last_feedback": _rel(last_feedback, project_root),
99
+ })
100
+
101
+ trampolines: list[dict] = []
102
+ for row in matrix["platforms"]:
103
+ platform = row["platform"]
104
+ needs_trampoline = bool(row["bindings"]) and platform not in NATIVE_DISPATCH_PLATFORMS
105
+ tpath = _trampoline_for(platform)
106
+ trampolines.append({
107
+ "platform": platform,
108
+ "expected": _rel(tpath, REPO_ROOT),
109
+ "present": tpath.is_file(),
110
+ "required": needs_trampoline,
111
+ "missing": needs_trampoline and not tpath.is_file(),
112
+ })
113
+
114
+ return {
115
+ "schema_version": 1,
116
+ "platforms": matrix["platforms"],
117
+ "concerns": concerns,
118
+ "trampolines": trampolines,
119
+ }
120
+
121
+
122
+ def _render_table(payload: dict) -> str:
123
+ lines: list[str] = [hooks_status._render_table(payload), ""]
124
+ lines.append("Concerns")
125
+ lines.append("-" * 60)
126
+ for c in payload["concerns"]:
127
+ posture = "fail-closed" if c["fail_closed"] else "fail-open"
128
+ script_mark = "✅ " if c["script_present"] else "❌ "
129
+ lines.append(f"{script_mark}{c['concern']:<22} {posture:<11} {c['script'] or '(no script)'}")
130
+ if c["state_file"]:
131
+ lines.append(f" state: {c['state_file']}")
132
+ if c["last_feedback"]:
133
+ lines.append(f" feedback: {c['last_feedback']}")
134
+ lines.append("")
135
+ lines.append("Trampolines")
136
+ lines.append("-" * 60)
137
+ for t in payload["trampolines"]:
138
+ marker = "❌ " if t["missing"] else ("· " if not t["required"] else "✅ ")
139
+ suffix = "" if t["required"] else " (not required)"
140
+ lines.append(f"{marker}{t['platform']:<9} {t['expected']}{suffix}")
141
+ return "\n".join(lines)
142
+
143
+
144
+ def _final_exit_code(payload: dict, strict: bool) -> int:
145
+ if not strict:
146
+ return 0
147
+ rc = hooks_status._final_exit_code(payload, strict)
148
+ if rc:
149
+ return rc
150
+ if any(t["missing"] for t in payload["trampolines"]):
151
+ return 1
152
+ if any(not c["script_present"] for c in payload["concerns"]):
153
+ return 1
154
+ return 0
155
+
156
+
157
+ def main(argv: list[str] | None = None) -> int:
158
+ parser = argparse.ArgumentParser(description=__doc__)
159
+ parser.add_argument("--format", choices=["table", "json"], default="table")
160
+ parser.add_argument("--project-root", default=".",
161
+ help="Project root to inspect (default: cwd)")
162
+ parser.add_argument("--manifest", default=str(dispatch_hook.MANIFEST_PATH))
163
+ parser.add_argument("--strict", action="store_true",
164
+ help="Exit non-zero on missing bridges, trampolines, "
165
+ "or concern scripts (CI-friendly).")
166
+ args = parser.parse_args(argv)
167
+
168
+ manifest_path = Path(args.manifest)
169
+ if not manifest_path.exists():
170
+ sys.stderr.write(f"hooks_doctor: manifest missing at {manifest_path}\n")
171
+ return 2
172
+ manifest = dispatch_hook._load_yaml(manifest_path)
173
+ project_root = Path(args.project_root).resolve()
174
+ payload = collect(project_root, manifest)
175
+
176
+ if args.format == "json":
177
+ print(json.dumps(payload, indent=2, sort_keys=True))
178
+ else:
179
+ print(_render_table(payload))
180
+ return _final_exit_code(payload, args.strict)
181
+
182
+
183
+ if __name__ == "__main__":
184
+ raise SystemExit(main())
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env python3
2
+ """Lint the hook concern budget against `scripts/hook_manifest.yaml`.
3
+
4
+ P3.3 of `agents/roadmaps/road-to-proof-not-features.md`. Static gate
5
+ that mirrors the always-rule budget pattern:
6
+
7
+ - **max concerns per (platform, event)** — warns when any cell exceeds
8
+ the configured threshold. Default threshold is a placeholder sourced
9
+ from `current-max × 1.5, rounded up` until Phase 1 captures real
10
+ decision-trace evidence (`max(observed-in-Phase-1) × 1.5`).
11
+ - **fail-closed only for declared Tier-1 concerns** — errors when a
12
+ concern carries `fail_closed: true` without being listed in
13
+ `hooks.concern_budget.tier1_concerns`.
14
+
15
+ Out of scope for the static gate: **max execution time per concern**.
16
+ That signal lives in runtime decision-trace logs (Phase 2) and is
17
+ checked by a separate runtime probe once Phase 1 sessions produce
18
+ data — tracked as a P3.3 follow-up, not blocking this gate.
19
+
20
+ Defaults (override in `.agent-settings.yml`):
21
+
22
+ hooks:
23
+ concern_budget:
24
+ max_per_event: 8
25
+ tier1_concerns: []
26
+ hard_fail: false
27
+
28
+ Exit codes (warn-only mode, the default):
29
+
30
+ 0 — clean, OR violations exist but `hard_fail` is false
31
+ 1 — schema load failed (file absent / malformed)
32
+ 2 — `hard_fail: true` and at least one violation
33
+
34
+ Hard-fail mode is gated on Phase 1 evidence (≥10 captured sessions per
35
+ the roadmap exit criterion).
36
+
37
+ Invocation:
38
+
39
+ python3 scripts/lint_hook_concern_budget.py [--manifest PATH]
40
+ [--settings PATH]
41
+ [--strict]
42
+
43
+ `--strict` upgrades warn-only to hard-fail regardless of settings — for
44
+ CI lanes that want to surface the gate on every PR.
45
+ """
46
+ from __future__ import annotations
47
+
48
+ import argparse
49
+ import re
50
+ import sys
51
+ from pathlib import Path
52
+
53
+ REPO_ROOT = Path(__file__).resolve().parent.parent
54
+ DEFAULT_MANIFEST = REPO_ROOT / "scripts" / "hook_manifest.yaml"
55
+ DEFAULT_SETTINGS = REPO_ROOT / ".agent-settings.yml"
56
+
57
+ DEFAULT_MAX_PER_EVENT = 8
58
+ DEFAULT_TIER1: list[str] = []
59
+ DEFAULT_HARD_FAIL = False
60
+
61
+
62
+ def _load_manifest(path: Path) -> dict:
63
+ sys.path.insert(0, str(REPO_ROOT / "scripts"))
64
+ from hooks.dispatch_hook import _load_yaml # noqa: E402
65
+ return _load_yaml(path)
66
+
67
+
68
+ def _read_settings_block(settings_path: Path) -> dict:
69
+ """Minimal YAML walk for `hooks.concern_budget.*`. Mirrors the
70
+ pattern used by `scripts/minimal_safe_diff_hook.py` — no PyYAML
71
+ dependency, tolerant of missing keys / blocks."""
72
+ out: dict = {}
73
+ if not settings_path.is_file():
74
+ return out
75
+ in_hooks = False
76
+ in_budget = False
77
+ in_tier1 = False
78
+ try:
79
+ text = settings_path.read_text(encoding="utf-8")
80
+ except OSError:
81
+ return out
82
+ for raw in text.splitlines():
83
+ line = raw.rstrip()
84
+ if re.match(r"^hooks\s*:\s*(?:#.*)?$", line):
85
+ in_hooks, in_budget, in_tier1 = True, False, False
86
+ continue
87
+ if in_hooks and re.match(r"^\S", line):
88
+ in_hooks = in_budget = in_tier1 = False
89
+ if in_hooks and re.match(r"^\s{2}concern_budget\s*:\s*(?:#.*)?$", line):
90
+ in_budget, in_tier1 = True, False
91
+ continue
92
+ if in_budget and re.match(r"^\s{2}\S", line):
93
+ in_budget = in_tier1 = False
94
+ if in_budget:
95
+ m = re.match(r"^\s{4}max_per_event\s*:\s*(\d+)", line)
96
+ if m:
97
+ out["max_per_event"] = int(m.group(1))
98
+ in_tier1 = False
99
+ continue
100
+ m = re.match(r"^\s{4}hard_fail\s*:\s*(true|false)", line)
101
+ if m:
102
+ out["hard_fail"] = m.group(1) == "true"
103
+ in_tier1 = False
104
+ continue
105
+ if re.match(r"^\s{4}tier1_concerns\s*:\s*\[\s*\]", line):
106
+ out["tier1_concerns"] = []
107
+ in_tier1 = False
108
+ continue
109
+ if re.match(r"^\s{4}tier1_concerns\s*:\s*(?:#.*)?$", line):
110
+ out.setdefault("tier1_concerns", [])
111
+ in_tier1 = True
112
+ continue
113
+ if in_tier1:
114
+ m = re.match(r"^\s{6}-\s*([A-Za-z0-9_\-]+)", line)
115
+ if m:
116
+ out.setdefault("tier1_concerns", []).append(m.group(1))
117
+ return out
118
+
119
+
120
+ def _check_concern_counts(manifest: dict, max_per_event: int,
121
+ warnings: list[str]) -> None:
122
+ platforms = manifest.get("platforms") or {}
123
+ if not isinstance(platforms, dict):
124
+ return
125
+ for plat, block in platforms.items():
126
+ if not isinstance(block, dict) or block.get("fallback_only"):
127
+ continue
128
+ for event, names in block.items():
129
+ if not isinstance(names, list):
130
+ continue
131
+ count = len(names)
132
+ if count > max_per_event:
133
+ warnings.append(
134
+ f"platforms.{plat}.{event}: {count} concerns "
135
+ f"(threshold {max_per_event}). Trim or raise "
136
+ "hooks.concern_budget.max_per_event in .agent-settings.yml."
137
+ )
138
+
139
+
140
+ def _check_fail_closed_tier(manifest: dict, tier1: list[str],
141
+ errors: list[str]) -> None:
142
+ concerns = manifest.get("concerns") or {}
143
+ if not isinstance(concerns, dict):
144
+ return
145
+ allowed = set(tier1)
146
+ for name, spec in concerns.items():
147
+ if not isinstance(spec, dict):
148
+ continue
149
+ if spec.get("fail_closed") is True and name not in allowed:
150
+ errors.append(
151
+ f"concerns.{name}: fail_closed=true but not declared in "
152
+ "hooks.concern_budget.tier1_concerns. Promotion to Tier-1 "
153
+ "is explicit opt-in (Phase 1 evidence required)."
154
+ )
155
+
156
+
157
+ def lint(manifest_path: Path, settings_path: Path, *,
158
+ strict: bool = False) -> int:
159
+ if not manifest_path.is_file():
160
+ sys.stderr.write(f"lint_hook_concern_budget: file not found: "
161
+ f"{manifest_path}\n")
162
+ return 1
163
+ try:
164
+ manifest = _load_manifest(manifest_path)
165
+ except Exception as exc: # pragma: no cover
166
+ sys.stderr.write(f"lint_hook_concern_budget: load error: {exc}\n")
167
+ return 1
168
+ if not isinstance(manifest, dict):
169
+ sys.stderr.write("lint_hook_concern_budget: manifest is not a mapping\n")
170
+ return 1
171
+
172
+ settings = _read_settings_block(settings_path)
173
+ max_per_event = settings.get("max_per_event", DEFAULT_MAX_PER_EVENT)
174
+ tier1 = settings.get("tier1_concerns", DEFAULT_TIER1)
175
+ hard_fail = settings.get("hard_fail", DEFAULT_HARD_FAIL) or strict
176
+
177
+ warnings: list[str] = []
178
+ errors: list[str] = []
179
+ _check_concern_counts(manifest, max_per_event, warnings)
180
+ _check_fail_closed_tier(manifest, tier1, errors)
181
+
182
+ for w in warnings:
183
+ sys.stderr.write(f"warn: {w}\n")
184
+ for e in errors:
185
+ sys.stderr.write(f"error: {e}\n")
186
+
187
+ if hard_fail and (warnings or errors):
188
+ return 2
189
+ return 0
190
+
191
+
192
+ def main(argv: list[str] | None = None) -> int:
193
+ parser = argparse.ArgumentParser(description=__doc__)
194
+ parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST)
195
+ parser.add_argument("--settings", type=Path, default=DEFAULT_SETTINGS)
196
+ parser.add_argument("--strict", action="store_true",
197
+ help="upgrade warn-only to hard-fail")
198
+ args = parser.parse_args(argv)
199
+ return lint(args.manifest, args.settings, strict=args.strict)
200
+
201
+
202
+ if __name__ == "__main__":
203
+ raise SystemExit(main())
@@ -26,10 +26,13 @@ from __future__ import annotations
26
26
 
27
27
  import argparse
28
28
  import json
29
+ import os
29
30
  import subprocess
30
31
  import sys
31
32
  from pathlib import Path
32
33
 
34
+ REPLAY_ENV_VAR = "AGENT_CONFIG_REPLAY"
35
+
33
36
  # Tools whose successful execution can write to a roadmap file. We keep
34
37
  # the list explicit so an unknown tool name (e.g. a new MCP tool that
35
38
  # happens to mention a roadmap path in its input) does not trigger a
@@ -134,6 +137,14 @@ def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
134
137
  file=sys.stderr)
135
138
  return 0
136
139
 
140
+ # Replay mode (`AGENT_CONFIG_REPLAY=1`) skips the regenerator subprocess
141
+ # so fixture dispatches never rewrite agents/roadmaps-progress.md.
142
+ if os.environ.get(REPLAY_ENV_VAR, "").strip() == "1":
143
+ if verbose:
144
+ print("roadmap-progress-hook: replay mode, skipping regenerator",
145
+ file=sys.stderr)
146
+ return 0
147
+
137
148
  try:
138
149
  subprocess.run(
139
150
  [sys.executable, str(script)],