@event4u/agent-config 1.17.0 → 1.19.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 (158) hide show
  1. package/.agent-src/commands/council/default.md +74 -76
  2. package/.agent-src/commands/feature/roadmap.md +22 -0
  3. package/.agent-src/commands/roadmap/create.md +38 -6
  4. package/.agent-src/commands/roadmap/execute.md +36 -9
  5. package/.agent-src/rules/agent-authority.md +1 -0
  6. package/.agent-src/rules/agent-docs.md +1 -0
  7. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  8. package/.agent-src/rules/architecture.md +1 -0
  9. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  10. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  11. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  12. package/.agent-src/rules/augment-portability.md +1 -0
  13. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  14. package/.agent-src/rules/autonomous-execution.md +1 -0
  15. package/.agent-src/rules/capture-learnings.md +1 -0
  16. package/.agent-src/rules/chat-history-cadence.md +34 -0
  17. package/.agent-src/rules/chat-history-ownership.md +1 -0
  18. package/.agent-src/rules/chat-history-visibility.md +1 -0
  19. package/.agent-src/rules/cli-output-handling.md +2 -2
  20. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  21. package/.agent-src/rules/commit-conventions.md +1 -0
  22. package/.agent-src/rules/commit-policy.md +1 -0
  23. package/.agent-src/rules/context-hygiene.md +28 -0
  24. package/.agent-src/rules/direct-answers.md +18 -26
  25. package/.agent-src/rules/docker-commands.md +1 -0
  26. package/.agent-src/rules/docs-sync.md +1 -0
  27. package/.agent-src/rules/downstream-changes.md +1 -0
  28. package/.agent-src/rules/e2e-testing.md +1 -0
  29. package/.agent-src/rules/guidelines.md +1 -0
  30. package/.agent-src/rules/improve-before-implement.md +1 -0
  31. package/.agent-src/rules/language-and-tone.md +1 -0
  32. package/.agent-src/rules/laravel-translations.md +1 -0
  33. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  34. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  35. package/.agent-src/rules/missing-tool-handling.md +1 -0
  36. package/.agent-src/rules/model-recommendation.md +1 -0
  37. package/.agent-src/rules/no-cheap-questions.md +15 -21
  38. package/.agent-src/rules/no-roadmap-references.md +1 -0
  39. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  40. package/.agent-src/rules/onboarding-gate.md +33 -0
  41. package/.agent-src/rules/package-ci-checks.md +1 -0
  42. package/.agent-src/rules/php-coding.md +1 -0
  43. package/.agent-src/rules/preservation-guard.md +1 -0
  44. package/.agent-src/rules/review-routing-awareness.md +1 -0
  45. package/.agent-src/rules/reviewer-awareness.md +1 -0
  46. package/.agent-src/rules/roadmap-progress-sync.md +49 -0
  47. package/.agent-src/rules/role-mode-adherence.md +2 -2
  48. package/.agent-src/rules/rule-type-governance.md +29 -0
  49. package/.agent-src/rules/runtime-safety.md +1 -0
  50. package/.agent-src/rules/scope-control.md +1 -0
  51. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  52. package/.agent-src/rules/size-enforcement.md +1 -0
  53. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  54. package/.agent-src/rules/skill-quality.md +1 -0
  55. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  56. package/.agent-src/rules/think-before-action.md +1 -0
  57. package/.agent-src/rules/token-efficiency.md +1 -0
  58. package/.agent-src/rules/tool-safety.md +1 -0
  59. package/.agent-src/rules/ui-audit-gate.md +1 -0
  60. package/.agent-src/rules/upstream-proposal.md +1 -0
  61. package/.agent-src/rules/user-interaction.md +1 -0
  62. package/.agent-src/rules/verify-before-complete.md +1 -0
  63. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  64. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  65. package/.agent-src/templates/agent-settings.md +16 -0
  66. package/.agent-src/templates/roadmaps.md +12 -3
  67. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +9 -0
  68. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -0
  69. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -0
  70. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  71. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +111 -0
  72. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  73. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  74. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  75. package/.claude-plugin/marketplace.json +1 -1
  76. package/CHANGELOG.md +97 -0
  77. package/README.md +20 -20
  78. package/config/agent-settings.template.yml +23 -0
  79. package/docs/architecture.md +1 -1
  80. package/docs/catalog.md +5 -2
  81. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  82. package/docs/contracts/decision-trace-v1.md +146 -0
  83. package/docs/contracts/file-ownership-matrix.json +7 -0
  84. package/docs/contracts/hook-architecture-v1.md +213 -0
  85. package/docs/contracts/load-context-budget-model.md +80 -0
  86. package/docs/contracts/load-context-schema.md +20 -0
  87. package/docs/contracts/memory-visibility-v1.md +138 -0
  88. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  89. package/docs/contracts/roadmap-complexity-standard.md +137 -0
  90. package/docs/contracts/rule-interactions.yml +22 -0
  91. package/docs/customization.md +1 -0
  92. package/docs/development.md +4 -1
  93. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +134 -0
  94. package/docs/guidelines/agent-infra/direct-answers-demos.md +145 -0
  95. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  96. package/docs/guidelines/agent-infra/verify-before-complete-demos.md +128 -0
  97. package/package.json +1 -1
  98. package/scripts/agent-config +64 -0
  99. package/scripts/ai_council/bundler.py +3 -3
  100. package/scripts/ai_council/clients.py +24 -8
  101. package/scripts/ai_council/one_off_archive/2026-05/README.md +67 -0
  102. package/scripts/ai_council/one_off_archive/2026-05/_one_off_budget_v2_audit.py +206 -0
  103. package/scripts/ai_council/{_one_off_roundtrip.py → one_off_archive/2026-05/_one_off_roundtrip.py} +13 -8
  104. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  105. package/scripts/ai_council/session.py +92 -0
  106. package/scripts/build_rule_trigger_matrix.py +360 -0
  107. package/scripts/capture_showcase_session.py +361 -0
  108. package/scripts/chat_history.py +11 -1
  109. package/scripts/check_always_budget.py +46 -2
  110. package/scripts/check_one_off_location.py +81 -0
  111. package/scripts/check_references.py +6 -0
  112. package/scripts/compress.py +5 -2
  113. package/scripts/context_hygiene_hook.py +181 -0
  114. package/scripts/council_cli.py +357 -0
  115. package/scripts/hook_manifest.yaml +184 -0
  116. package/scripts/hooks/__init__.py +1 -0
  117. package/scripts/hooks/augment-context-hygiene.sh +55 -0
  118. package/scripts/hooks/augment-dispatcher.sh +72 -0
  119. package/scripts/hooks/augment-onboarding-gate.sh +55 -0
  120. package/scripts/hooks/cline-dispatcher.sh +86 -0
  121. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  122. package/scripts/hooks/dispatch_hook.py +348 -0
  123. package/scripts/hooks/envelope.py +98 -0
  124. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  125. package/scripts/hooks/state_io.py +122 -0
  126. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  127. package/scripts/hooks_status.py +146 -0
  128. package/scripts/install.py +728 -51
  129. package/scripts/install.sh +1 -1
  130. package/scripts/lint_examples.py +98 -0
  131. package/scripts/lint_hook_manifest.py +216 -0
  132. package/scripts/lint_one_off_age.py +184 -0
  133. package/scripts/lint_roadmap_complexity.py +127 -0
  134. package/scripts/lint_rule_tiers.py +78 -0
  135. package/scripts/lint_showcase_sessions.py +148 -0
  136. package/scripts/minimal_safe_diff_hook.py +245 -0
  137. package/scripts/onboarding_gate_hook.py +142 -0
  138. package/scripts/readme_linter.py +12 -3
  139. package/scripts/roadmap_progress_hook.py +5 -0
  140. package/scripts/schemas/rule.schema.json +5 -0
  141. package/scripts/sync_agent_settings.py +32 -129
  142. package/scripts/sync_yaml_rt.py +734 -0
  143. package/scripts/verify_before_complete_hook.py +216 -0
  144. /package/scripts/ai_council/{_one_off_2a4_acceptance.py → one_off_archive/2026-05/_one_off_2a4_acceptance.py} +0 -0
  145. /package/scripts/ai_council/{_one_off_context_layer_v1_estimate.py → one_off_archive/2026-05/_one_off_context_layer_v1_estimate.py} +0 -0
  146. /package/scripts/ai_council/{_one_off_context_layer_v1_review.py → one_off_archive/2026-05/_one_off_context_layer_v1_review.py} +0 -0
  147. /package/scripts/ai_council/{_one_off_followups_review.py → one_off_archive/2026-05/_one_off_followups_review.py} +0 -0
  148. /package/scripts/ai_council/{_one_off_nondestructive_inline_audit.py → one_off_archive/2026-05/_one_off_nondestructive_inline_audit.py} +0 -0
  149. /package/scripts/{_one_off_phase4_dispatch_latency.py → ai_council/one_off_archive/2026-05/_one_off_phase4_dispatch_latency.py} +0 -0
  150. /package/scripts/{_one_off_phase6_trigger_jaccard.py → ai_council/one_off_archive/2026-05/_one_off_phase6_trigger_jaccard.py} +0 -0
  151. /package/scripts/ai_council/{_one_off_phase_2a_budget_rebalance.py → one_off_archive/2026-05/_one_off_phase_2a_budget_rebalance.py} +0 -0
  152. /package/scripts/ai_council/{_one_off_phase_2a_post_revert.py → one_off_archive/2026-05/_one_off_phase_2a_post_revert.py} +0 -0
  153. /package/scripts/ai_council/{_one_off_rebalancing_audit.py → one_off_archive/2026-05/_one_off_rebalancing_audit.py} +0 -0
  154. /package/scripts/ai_council/{_one_off_rule_hardening_v1.py → one_off_archive/2026-05/_one_off_rule_hardening_v1.py} +0 -0
  155. /package/scripts/ai_council/{_one_off_structural_open_questions.py → one_off_archive/2026-05/_one_off_structural_open_questions.py} +0 -0
  156. /package/scripts/ai_council/{_one_off_structural_optimization.py → one_off_archive/2026-05/_one_off_structural_optimization.py} +0 -0
  157. /package/scripts/ai_council/{_one_off_structural_v3_gaps.py → one_off_archive/2026-05/_one_off_structural_v3_gaps.py} +0 -0
  158. /package/scripts/ai_council/{_one_off_structural_v3_review.py → one_off_archive/2026-05/_one_off_structural_v3_review.py} +0 -0
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env python3
2
+ """Universal hook dispatcher — single entry point for every platform.
3
+
4
+ Per `docs/contracts/hook-architecture-v1.md`. Reads the manifest at
5
+ `scripts/hook_manifest.yaml`, resolves which concerns fire on the given
6
+ (platform, event) tuple, and runs each concern sequentially with the
7
+ stdin envelope contract. Reduces concern exit codes per the spec
8
+ (0=allow, 1=block, 2=warn, ≥3=error → fail-open unless concern is
9
+ fail_closed).
10
+
11
+ Invocation:
12
+
13
+ python3 scripts/hooks/dispatch_hook.py \\
14
+ --platform <name> \\
15
+ --event <agent-config-event> \\
16
+ [--native-event <platform-event>] \\
17
+ < platform-payload.json
18
+
19
+ Per-platform shell trampolines under `scripts/hooks/<platform>-dispatcher.sh`
20
+ extract the workspace root from the platform payload, cd there, then call
21
+ this script. Trampolines never read the manifest themselves.
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import subprocess
29
+ import sys
30
+ import time
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+
34
+ REPO_ROOT = Path(__file__).resolve().parents[2]
35
+ MANIFEST_PATH = REPO_ROOT / "scripts" / "hook_manifest.yaml"
36
+
37
+ # Lazy import — we want this module to be importable even if the
38
+ # hooks package state_io has changed (test isolation).
39
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
40
+ from state_io import atomic_write_json, feedback_dir # noqa: E402
41
+
42
+ EXIT_ALLOW = 0
43
+ EXIT_BLOCK = 1
44
+ EXIT_WARN = 2
45
+
46
+ # Per Council Round 2 (Q3): `agent_error` covers agent-level crashes
47
+ # that are not concern-triggered, so chat-history can checkpoint
48
+ # partial sessions on abnormal exit.
49
+ EVENT_VOCABULARY = {
50
+ "session_start", "session_end",
51
+ "user_prompt_submit",
52
+ "pre_tool_use", "post_tool_use",
53
+ "stop", "pre_compact",
54
+ "agent_error",
55
+ }
56
+
57
+ _SEVERITY_BY_EXIT = {
58
+ EXIT_ALLOW: "allow",
59
+ EXIT_BLOCK: "block",
60
+ EXIT_WARN: "warn",
61
+ }
62
+
63
+
64
+ def _severity_for(rc: int) -> str:
65
+ return _SEVERITY_BY_EXIT.get(rc, "error")
66
+
67
+
68
+ def _now_iso() -> str:
69
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
70
+
71
+
72
+ def _resolve_session_id(envelope: dict) -> str:
73
+ sid = envelope.get("session_id") or ""
74
+ if sid:
75
+ return str(sid)
76
+ # Fallback so the feedback dir always has a unique slot per
77
+ # invocation. Format: dispatch-<unix_ts>-<pid>. Not stable
78
+ # across invocations — that is the point.
79
+ return f"dispatch-{int(time.time())}-{os.getpid()}"
80
+
81
+
82
+ def _parse_concern_stdout(stdout_text: str) -> dict:
83
+ """Concern stdout MAY be a JSON object with decision/reason. Tolerate
84
+ empty / non-JSON / non-dict output per the contract."""
85
+ text = (stdout_text or "").strip()
86
+ if not text:
87
+ return {}
88
+ try:
89
+ parsed = json.loads(text)
90
+ except (ValueError, TypeError):
91
+ return {"_raw_stdout": text[:500]}
92
+ return parsed if isinstance(parsed, dict) else {"_raw": parsed}
93
+
94
+
95
+ def _load_yaml(path: Path) -> dict:
96
+ """Minimal manifest loader — prefers PyYAML, falls back to a stub
97
+ parser so the dispatcher works even before consumer projects pip-install
98
+ PyYAML. The fallback is deliberately narrow: it understands only the
99
+ flat dict / list-of-strings / null shape the manifest uses."""
100
+ text = path.read_text(encoding="utf-8")
101
+ try:
102
+ import yaml # type: ignore[import-not-found]
103
+ return yaml.safe_load(text) or {}
104
+ except ImportError:
105
+ pass
106
+ return _fallback_yaml(text)
107
+
108
+
109
+ def _fallback_yaml(text: str) -> dict: # noqa: C901 — flat parser is unavoidably long
110
+ """Indent-aware mini-parser for the manifest's flat shape only.
111
+ Handles: scalars, `key: null`, `key: true/false`, `key: [a, b]`.
112
+ Drops comments + blank lines. Two-space indent assumed."""
113
+ root: dict = {}
114
+ stack: list[tuple[int, dict]] = [(-1, root)]
115
+ for raw in text.splitlines():
116
+ line = raw.split("#", 1)[0].rstrip()
117
+ if not line.strip():
118
+ continue
119
+ indent = len(line) - len(line.lstrip(" "))
120
+ while stack and stack[-1][0] >= indent:
121
+ stack.pop()
122
+ parent = stack[-1][1] if stack else root
123
+ body = line.strip()
124
+ if ":" not in body:
125
+ continue
126
+ key, _, val = body.partition(":")
127
+ key, val = key.strip(), val.strip()
128
+ if not val:
129
+ new: dict = {}
130
+ parent[key] = new
131
+ stack.append((indent, new))
132
+ elif val.lower() in ("null", "~", ""):
133
+ parent[key] = None
134
+ elif val.lower() == "true":
135
+ parent[key] = True
136
+ elif val.lower() == "false":
137
+ parent[key] = False
138
+ elif val.startswith("[") and val.endswith("]"):
139
+ inner = val[1:-1].strip()
140
+ parent[key] = [s.strip() for s in inner.split(",") if s.strip()] if inner else []
141
+ elif val.lstrip("-").isdigit():
142
+ parent[key] = int(val)
143
+ else:
144
+ parent[key] = val.strip("'\"")
145
+ return root
146
+
147
+
148
+ def _resolve_concerns(manifest: dict, platform: str, event: str) -> list[dict]:
149
+ """Return the ordered concern definitions for (platform, event)."""
150
+ platforms = manifest.get("platforms") or {}
151
+ block = platforms.get(platform)
152
+ if not block:
153
+ return []
154
+ if isinstance(block, dict) and block.get("fallback_only"):
155
+ return []
156
+ names = (block or {}).get(event) or []
157
+ if not isinstance(names, list):
158
+ return []
159
+ concerns_def = manifest.get("concerns") or {}
160
+ out: list[dict] = []
161
+ for name in names:
162
+ spec = concerns_def.get(name)
163
+ if not spec:
164
+ sys.stderr.write(f"dispatch_hook: unknown concern '{name}' in manifest\n")
165
+ continue
166
+ out.append({"name": name, **spec})
167
+ return out
168
+
169
+
170
+ def _build_envelope(args: argparse.Namespace, payload_text: str) -> dict:
171
+ try:
172
+ payload = json.loads(payload_text) if payload_text.strip() else {}
173
+ if not isinstance(payload, dict):
174
+ payload = {"_raw": payload}
175
+ except (ValueError, TypeError):
176
+ payload = {"_raw": payload_text}
177
+ return {
178
+ "schema_version": 1,
179
+ "platform": args.platform,
180
+ "event": args.event,
181
+ "native_event": args.native_event or "",
182
+ "session_id": payload.get("session_id") or os.environ.get("AGENT_SESSION_ID", ""),
183
+ "workspace_root": str(Path.cwd()),
184
+ "payload": payload,
185
+ "settings": {},
186
+ }
187
+
188
+
189
+ def _run_concern(concern: dict, envelope: dict) -> tuple[int, str, str, int]:
190
+ """Invoke one concern with the envelope on stdin.
191
+
192
+ Returns (rc, stderr_text, stdout_text, duration_ms).
193
+
194
+ Concerns run with CWD = consumer workspace (envelope.workspace_root),
195
+ NOT the agent-config package root — concerns resolve `agents/state/`
196
+ and other consumer-local paths relative to CWD. The script *itself*
197
+ lives in the package (REPO_ROOT), so we resolve it absolutely.
198
+ """
199
+ script = REPO_ROOT / concern["script"]
200
+ cmd = [sys.executable, str(script), *(concern.get("args") or [])]
201
+ cmd.extend(["--platform", envelope.get("platform", "generic")])
202
+ workspace = envelope.get("workspace_root") or str(Path.cwd())
203
+ started = time.monotonic()
204
+ try:
205
+ proc = subprocess.run(
206
+ cmd,
207
+ input=json.dumps(envelope),
208
+ capture_output=True,
209
+ text=True,
210
+ cwd=workspace,
211
+ timeout=30,
212
+ check=False,
213
+ )
214
+ except (OSError, subprocess.TimeoutExpired) as exc:
215
+ elapsed = int((time.monotonic() - started) * 1000)
216
+ return (3, f"{concern.get('name')}: {exc}", "", elapsed)
217
+ elapsed = int((time.monotonic() - started) * 1000)
218
+ return (proc.returncode, proc.stderr or "", proc.stdout or "", elapsed)
219
+
220
+
221
+ def _reduce(rcs: list[int]) -> int:
222
+ if any(rc == EXIT_BLOCK for rc in rcs):
223
+ return EXIT_BLOCK
224
+ if any(rc == EXIT_WARN for rc in rcs):
225
+ return EXIT_WARN
226
+ return EXIT_ALLOW
227
+
228
+
229
+ def _write_feedback(envelope: dict, session_id: str, entries: list[dict],
230
+ final_rc: int, started_at: str) -> None:
231
+ """Write per-concern feedback files + summary rollup.
232
+
233
+ Per Council Round 2 (Q1): exit-code reduction collapses the
234
+ severity ladder to a single platform-native code; this dir
235
+ surfaces the per-concern detail to humans / `task hooks-status`.
236
+
237
+ Errors writing feedback are non-fatal — feedback is observability,
238
+ not control flow. We only swallow IO errors here; fail-open
239
+ matches the dispatcher's overall posture.
240
+ """
241
+ workspace = envelope.get("workspace_root") or str(Path.cwd())
242
+ state_root = Path(workspace) / "agents" / "state"
243
+ fb_dir = feedback_dir(state_root, session_id)
244
+ try:
245
+ fb_dir.mkdir(parents=True, exist_ok=True)
246
+ except OSError as exc:
247
+ sys.stderr.write(f"dispatch_hook: feedback dir unavailable: {exc}\n")
248
+ return
249
+ for entry in entries:
250
+ target = fb_dir / f"{entry['concern']}.json"
251
+ try:
252
+ atomic_write_json(target, entry)
253
+ except OSError as exc:
254
+ sys.stderr.write(f"dispatch_hook: feedback write failed for "
255
+ f"{entry['concern']}: {exc}\n")
256
+ summary = {
257
+ "schema_version": 1,
258
+ "session_id": session_id,
259
+ "platform": envelope.get("platform"),
260
+ "event": envelope.get("event"),
261
+ "native_event": envelope.get("native_event") or "",
262
+ "started_at": started_at,
263
+ "completed_at": _now_iso(),
264
+ "final_exit_code": final_rc,
265
+ "final_severity": _severity_for(final_rc),
266
+ "concerns": [
267
+ {k: v for k, v in e.items()
268
+ if k in {"concern", "exit_code", "severity", "decision",
269
+ "reason", "duration_ms"}}
270
+ for e in entries
271
+ ],
272
+ }
273
+ try:
274
+ atomic_write_json(fb_dir / "summary.json", summary)
275
+ except OSError as exc:
276
+ sys.stderr.write(f"dispatch_hook: summary write failed: {exc}\n")
277
+
278
+
279
+ def main(argv: list[str] | None = None) -> int:
280
+ parser = argparse.ArgumentParser(description=__doc__)
281
+ parser.add_argument("--platform", required=True)
282
+ parser.add_argument("--event", required=True)
283
+ parser.add_argument("--native-event", default="")
284
+ parser.add_argument("--manifest", default=str(MANIFEST_PATH))
285
+ parser.add_argument("--dry-run", action="store_true",
286
+ help="Resolve concerns and print plan; do not invoke them.")
287
+ args = parser.parse_args(argv)
288
+
289
+ if args.event not in EVENT_VOCABULARY:
290
+ sys.stderr.write(f"dispatch_hook: unknown event '{args.event}'; allowed: "
291
+ f"{sorted(EVENT_VOCABULARY)}\n")
292
+ return EXIT_ALLOW # fail-open per contract for unknown events
293
+
294
+ manifest_path = Path(args.manifest)
295
+ if not manifest_path.exists():
296
+ sys.stderr.write(f"dispatch_hook: manifest missing at {manifest_path}\n")
297
+ return EXIT_ALLOW
298
+ manifest = _load_yaml(manifest_path)
299
+
300
+ payload_text = "" if sys.stdin.isatty() else sys.stdin.read()
301
+ concerns = _resolve_concerns(manifest, args.platform, args.event)
302
+
303
+ if args.dry_run:
304
+ plan = {"platform": args.platform, "event": args.event,
305
+ "concerns": [c["name"] for c in concerns]}
306
+ print(json.dumps(plan, indent=2))
307
+ return EXIT_ALLOW
308
+
309
+ if not concerns:
310
+ return EXIT_ALLOW # platform unsupported / fallback-only / empty slot
311
+
312
+ envelope = _build_envelope(args, payload_text)
313
+ session_id = _resolve_session_id(envelope)
314
+ started_at = _now_iso()
315
+ rcs: list[int] = []
316
+ feedback_entries: list[dict] = []
317
+ for concern in concerns:
318
+ concern_started = _now_iso()
319
+ rc, stderr_text, stdout_text, duration_ms = _run_concern(concern, envelope)
320
+ raw_rc = rc
321
+ if rc >= 3:
322
+ if not concern.get("fail_closed"):
323
+ rc = EXIT_ALLOW # fail-open
324
+ else:
325
+ rc = EXIT_BLOCK
326
+ if stderr_text:
327
+ sys.stderr.write(stderr_text)
328
+ rcs.append(rc)
329
+ reply = _parse_concern_stdout(stdout_text)
330
+ feedback_entries.append({
331
+ "concern": concern["name"],
332
+ "exit_code": rc,
333
+ "raw_exit_code": raw_rc,
334
+ "severity": _severity_for(rc),
335
+ "decision": reply.get("decision") or _severity_for(rc),
336
+ "reason": reply.get("reason"),
337
+ "duration_ms": duration_ms,
338
+ "started_at": concern_started,
339
+ "completed_at": _now_iso(),
340
+ "fail_closed": bool(concern.get("fail_closed")),
341
+ })
342
+ final_rc = _reduce(rcs)
343
+ _write_feedback(envelope, session_id, feedback_entries, final_rc, started_at)
344
+ return final_rc
345
+
346
+
347
+ if __name__ == "__main__":
348
+ raise SystemExit(main())
@@ -0,0 +1,98 @@
1
+ """Concern envelope helpers — read the dispatcher's stdin contract.
2
+
3
+ Per `docs/contracts/hook-architecture-v1.md`, the universal dispatcher
4
+ writes a JSON object to each concern's stdin with shape:
5
+
6
+ {
7
+ "schema_version": 1,
8
+ "platform": "augment",
9
+ "event": "stop",
10
+ "native_event": "Stop",
11
+ "session_id": "…",
12
+ "workspace_root": "/abs/path",
13
+ "payload": { /* opaque, platform-native */ },
14
+ "settings": { /* materialized .agent-settings.yml subset */ }
15
+ }
16
+
17
+ Concern scripts must accept BOTH the new envelope shape AND the legacy
18
+ "raw platform payload directly on stdin" shape — the latter is what every
19
+ existing trampoline produced before Phase 7.3, and direct invocations
20
+ (e.g. `./agent-config chat-history:hook --platform claude < event.json`)
21
+ are still supported during the migration window.
22
+
23
+ `unwrap()` returns the (envelope, payload, platform) triple. When
24
+ called with raw platform JSON it synthesises a minimal envelope so
25
+ callers never need to branch.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ from typing import Any
31
+
32
+ ENVELOPE_KEYS = ("schema_version", "platform", "event", "payload")
33
+
34
+
35
+ def looks_like_envelope(obj: Any) -> bool:
36
+ """Heuristic — `obj` is a dispatcher envelope if it is a dict that
37
+ carries every required envelope key. The `payload` value itself is
38
+ the concern's platform-native data, so a payload that happens to
39
+ contain `schema_version` does NOT trigger this branch (the four
40
+ keys must all be at the top level).
41
+ """
42
+ if not isinstance(obj, dict):
43
+ return False
44
+ return all(key in obj for key in ENVELOPE_KEYS)
45
+
46
+
47
+ def unwrap(stdin_text: str, default_platform: str = "generic") -> tuple[dict, dict, str]:
48
+ """Parse stdin and return (envelope, payload, platform).
49
+
50
+ - Empty / non-JSON stdin → ({}, {}, default_platform).
51
+ - Raw platform JSON → synth envelope with schema_version=1,
52
+ platform=default_platform, event="", payload=<raw>.
53
+ - Already-an-envelope → return as-is, payload extracted.
54
+
55
+ Never raises — concerns must remain crash-safe in the agent loop.
56
+ """
57
+ text = (stdin_text or "").strip()
58
+ if not text:
59
+ return ({}, {}, default_platform)
60
+ try:
61
+ decoded = json.loads(text)
62
+ except (ValueError, TypeError):
63
+ return ({}, {}, default_platform)
64
+
65
+ if looks_like_envelope(decoded):
66
+ payload = decoded.get("payload") or {}
67
+ if not isinstance(payload, dict):
68
+ payload = {}
69
+ platform = str(decoded.get("platform") or default_platform)
70
+ return (decoded, payload, platform)
71
+
72
+ # Legacy direct-invocation path. Whatever shape the platform sent
73
+ # is treated as the payload itself; callers fall back to their
74
+ # pre-7.3 extraction logic.
75
+ payload = decoded if isinstance(decoded, dict) else {}
76
+ return (
77
+ {
78
+ "schema_version": 1,
79
+ "platform": default_platform,
80
+ "event": "",
81
+ "native_event": "",
82
+ "session_id": "",
83
+ "workspace_root": "",
84
+ "payload": payload,
85
+ "settings": {},
86
+ },
87
+ payload,
88
+ default_platform,
89
+ )
90
+
91
+
92
+ def envelope_field(envelope: dict, key: str, default: Any = "") -> Any:
93
+ """Safe accessor — concerns should treat unknown / missing keys as
94
+ forward-compat extensions and never raise."""
95
+ if not isinstance(envelope, dict):
96
+ return default
97
+ value = envelope.get(key)
98
+ return default if value is None else value
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env bash
2
+ # Gemini CLI universal hook trampoline (Phase 7.8,
3
+ # hook-architecture-v1.md).
4
+ #
5
+ # Routes Gemini hook events — fired from either the project-scope
6
+ # `.gemini/settings.json` or the user-scope `~/.gemini/settings.json`
7
+ # — into the active workspace's `./agent-config dispatch:hook`.
8
+ #
9
+ # Gemini event payload (per geminicli.com/docs/hooks/reference/):
10
+ # { "session_id": "...", "cwd": "...",
11
+ # "hook_event_name": "SessionStart" | "BeforeAgent" | ...,
12
+ # <event-specific fields: source, prompt, tool_name, ...> }
13
+ #
14
+ # Workspace resolution — Gemini does NOT pass a workspace_roots array
15
+ # the way Cursor/Cline do. Instead:
16
+ # 1. Project-scope hook → cwd is the workspace root (Gemini fires
17
+ # hooks with the project as cwd).
18
+ # `$PWD` containing `./agent-config` is the happy path.
19
+ # 2. User-scope hook → cwd may be the workspace, but for some
20
+ # events Gemini executes from `$HOME` or a tmp dir. Fall back to:
21
+ # - the JSON payload's `cwd` field
22
+ # - walk up to nearest .agent-settings.yml
23
+ # 3. Bail silently when no resolution succeeds — concerns are
24
+ # observe-only at this layer; chat-history / roadmap-progress /
25
+ # context-hygiene never block, and onboarding-gate writes state,
26
+ # not exit code.
27
+ #
28
+ # Output — none on stdout. Gemini consumes JSON on stdout for
29
+ # context injection / decision; we don't inject anything from this
30
+ # layer (concerns stream their own state via agents/state/.dispatcher/).
31
+ # SessionStart / SessionEnd are advisory in Gemini (continue/decision
32
+ # ignored), so we always exit 0.
33
+
34
+ set -u
35
+
36
+ # Args from the platform's settings.json command string:
37
+ # $1 = agent-config event name (session_start, stop, user_prompt_submit, ...)
38
+ # $2 = Gemini-native event name (SessionStart, BeforeAgent, ...)
39
+ EVENT="${1-}"
40
+ NATIVE_EVENT="${2-}"
41
+
42
+ if [ -z "$EVENT" ]; then
43
+ exit 0
44
+ fi
45
+
46
+ EVENT_DATA="$(cat)"
47
+
48
+ # 1. $PWD wins when it already looks like an agent-config workspace.
49
+ WORKSPACE=""
50
+ if [ -x "$PWD/agent-config" ] || [ -f "$PWD/.agent-settings.yml" ]; then
51
+ WORKSPACE="$PWD"
52
+ fi
53
+
54
+ # 2. Walk up from $PWD looking for .agent-settings.yml (covers
55
+ # sub-directory invocations).
56
+ if [ -z "$WORKSPACE" ]; then
57
+ candidate="$PWD"
58
+ while [ -n "$candidate" ] && [ "$candidate" != "/" ]; do
59
+ if [ -f "$candidate/.agent-settings.yml" ]; then
60
+ WORKSPACE="$candidate"
61
+ break
62
+ fi
63
+ candidate="$(dirname "$candidate")"
64
+ done
65
+ fi
66
+
67
+ # 3. Parse JSON `cwd` field from the payload.
68
+ if [ -z "$WORKSPACE" ]; then
69
+ if command -v jq >/dev/null 2>&1; then
70
+ EXTRACTED="$(printf '%s' "$EVENT_DATA" \
71
+ | jq -r '.cwd // empty' 2>/dev/null)"
72
+ elif command -v python3 >/dev/null 2>&1; then
73
+ EXTRACTED="$(printf '%s' "$EVENT_DATA" | python3 -c '
74
+ import json, sys
75
+ try:
76
+ data = json.load(sys.stdin)
77
+ except Exception:
78
+ sys.exit(0)
79
+ print(data.get("cwd") or "")
80
+ ' 2>/dev/null)"
81
+ else
82
+ EXTRACTED=""
83
+ fi
84
+ EXTRACTED="${EXTRACTED%$'\r'}"
85
+ if [ -n "$EXTRACTED" ]; then
86
+ candidate="$EXTRACTED"
87
+ if [ -f "$candidate" ]; then
88
+ candidate="$(dirname "$candidate")"
89
+ fi
90
+ while [ -n "$candidate" ] && [ "$candidate" != "/" ]; do
91
+ if [ -f "$candidate/.agent-settings.yml" ]; then
92
+ WORKSPACE="$candidate"
93
+ break
94
+ fi
95
+ candidate="$(dirname "$candidate")"
96
+ done
97
+ fi
98
+ fi
99
+
100
+ if [ -z "$WORKSPACE" ] || [ ! -d "$WORKSPACE" ]; then
101
+ exit 0
102
+ fi
103
+
104
+ cd "$WORKSPACE" 2>/dev/null || exit 0
105
+
106
+ if [ ! -x ./agent-config ]; then
107
+ exit 0
108
+ fi
109
+
110
+ printf '%s' "$EVENT_DATA" \
111
+ | ./agent-config dispatch:hook \
112
+ --platform gemini \
113
+ --event "$EVENT" \
114
+ --native-event "$NATIVE_EVENT" \
115
+ >/dev/null 2>&1 || true
116
+
117
+ exit 0
@@ -0,0 +1,122 @@
1
+ """Concurrency-safe state writes for hook concerns.
2
+
3
+ Per `docs/contracts/hook-architecture-v1.md` § Concurrency, every concern
4
+ that writes under `agents/state/` MUST:
5
+
6
+ 1. Acquire `fcntl.flock(LOCK_EX)` on `agents/state/.dispatcher.lock`.
7
+ 2. Write to a sibling `<dest>.tmp.<pid>` file in the same directory.
8
+ 3. `os.replace(tmp, dest)` — POSIX-atomic on the same filesystem.
9
+ 4. Release the lock.
10
+
11
+ The single shared lock is intentional: serialising state writes across
12
+ concerns is cheaper than per-file locks, and concerns already run
13
+ sequentially within one dispatcher invocation. Concurrent dispatcher
14
+ invocations (e.g. two platforms firing into the same workspace) are the
15
+ case this lock guards.
16
+
17
+ Cross-platform notes
18
+ --------------------
19
+ - `fcntl` is POSIX-only. On Windows the contract degrades gracefully:
20
+ the lock acquire is a no-op, atomic replace via `os.replace` still
21
+ holds, and torn-write risk is accepted (Windows is not a primary
22
+ agent-config platform — Cline tracks the upstream Windows-path issue
23
+ separately).
24
+ - The lock file lives under `agents/state/` which is gitignored.
25
+ - The lock is process-scoped, not session-scoped: each call opens,
26
+ locks, writes, releases, closes. No long-lived file handles.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ try:
36
+ import fcntl # type: ignore[import-not-found]
37
+ _HAS_FCNTL = True
38
+ except ImportError: # pragma: no cover — Windows
39
+ _HAS_FCNTL = False
40
+
41
+ LOCK_BASENAME = ".dispatcher.lock"
42
+
43
+
44
+ def _lock_path(state_dir: Path) -> Path:
45
+ return state_dir / LOCK_BASENAME
46
+
47
+
48
+ def atomic_write_json(target: Path, payload: Any, *, indent: int = 2) -> None:
49
+ """Write `payload` as JSON to `target` atomically and concurrency-safely.
50
+
51
+ `target` MUST sit under an `agents/state/` directory (or any other
52
+ directory the caller treats as the lock scope). The lock file is
53
+ `<target.parent>/.dispatcher.lock`. Caller does not need to create
54
+ the directory in advance — this function ensures it.
55
+ """
56
+ target = Path(target)
57
+ state_dir = target.parent
58
+ state_dir.mkdir(parents=True, exist_ok=True)
59
+ body = json.dumps(payload, indent=indent) + "\n"
60
+ _atomic_write_text(target, body)
61
+
62
+
63
+ def atomic_write_text(target: Path, text: str) -> None:
64
+ """Write text to `target` atomically and concurrency-safely. Same
65
+ locking discipline as `atomic_write_json` — useful for non-JSON
66
+ state payloads (chat-history transcript, status text)."""
67
+ target = Path(target)
68
+ state_dir = target.parent
69
+ state_dir.mkdir(parents=True, exist_ok=True)
70
+ _atomic_write_text(target, text)
71
+
72
+
73
+ def _atomic_write_text(target: Path, text: str) -> None:
74
+ tmp = target.with_suffix(target.suffix + f".tmp.{os.getpid()}")
75
+ lock_path = _lock_path(target.parent)
76
+ # `os.O_CREAT | os.O_RDWR` — we don't truncate the lock file, just
77
+ # need an fd to flock. Mode 0o644 is fine; the file holds no data.
78
+ if _HAS_FCNTL:
79
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
80
+ try:
81
+ fcntl.flock(fd, fcntl.LOCK_EX)
82
+ try:
83
+ tmp.write_text(text, encoding="utf-8")
84
+ os.replace(str(tmp), str(target))
85
+ finally:
86
+ fcntl.flock(fd, fcntl.LOCK_UN)
87
+ finally:
88
+ os.close(fd)
89
+ else: # pragma: no cover — Windows fallback, no flock
90
+ tmp.write_text(text, encoding="utf-8")
91
+ os.replace(str(tmp), str(target))
92
+
93
+
94
+ FEEDBACK_DIRNAME = ".dispatcher"
95
+
96
+
97
+ def feedback_dir(state_root: Path, session_id: str) -> Path:
98
+ """Return the per-session feedback directory under state_root.
99
+
100
+ Layout:
101
+ <state_root>/.dispatcher/<session_id>/
102
+ <concern>.json — one per concern that ran
103
+ summary.json — rollup written by the dispatcher
104
+
105
+ Per Council Round 2 (2026-05-04): exit-code reduction collapses
106
+ multiple concern signals into a single platform-native code; the
107
+ feedback dir surfaces the per-concern detail to humans and
108
+ `task hooks-status` without re-routing control flow.
109
+ """
110
+ safe_session = session_id or "unknown-session"
111
+ # Defence-in-depth: refuse path traversal in session_id.
112
+ safe_session = safe_session.replace("/", "_").replace("\\", "_").replace("..", "_")
113
+ return Path(state_root) / FEEDBACK_DIRNAME / safe_session
114
+
115
+
116
+ __all__ = [
117
+ "atomic_write_json",
118
+ "atomic_write_text",
119
+ "feedback_dir",
120
+ "LOCK_BASENAME",
121
+ "FEEDBACK_DIRNAME",
122
+ ]