@event4u/agent-config 1.18.0 → 1.20.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 (181) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +77 -82
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/feature/roadmap.md +22 -0
  9. package/.agent-src/commands/roadmap/create.md +38 -6
  10. package/.agent-src/commands/roadmap/execute.md +36 -9
  11. package/.agent-src/commands/sync-gitignore.md +1 -1
  12. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  13. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  14. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  15. package/.agent-src/rules/agent-authority.md +1 -0
  16. package/.agent-src/rules/agent-docs.md +1 -0
  17. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  18. package/.agent-src/rules/architecture.md +1 -0
  19. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  20. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  21. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  22. package/.agent-src/rules/augment-portability.md +1 -0
  23. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  24. package/.agent-src/rules/autonomous-execution.md +1 -0
  25. package/.agent-src/rules/capture-learnings.md +1 -0
  26. package/.agent-src/rules/cli-output-handling.md +2 -2
  27. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  28. package/.agent-src/rules/commit-conventions.md +1 -0
  29. package/.agent-src/rules/commit-policy.md +1 -0
  30. package/.agent-src/rules/context-hygiene.md +22 -0
  31. package/.agent-src/rules/direct-answers.md +11 -2
  32. package/.agent-src/rules/docker-commands.md +1 -0
  33. package/.agent-src/rules/docs-sync.md +1 -0
  34. package/.agent-src/rules/downstream-changes.md +1 -0
  35. package/.agent-src/rules/e2e-testing.md +1 -0
  36. package/.agent-src/rules/guidelines.md +1 -0
  37. package/.agent-src/rules/improve-before-implement.md +1 -0
  38. package/.agent-src/rules/language-and-tone.md +38 -6
  39. package/.agent-src/rules/laravel-translations.md +1 -0
  40. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  41. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  42. package/.agent-src/rules/missing-tool-handling.md +1 -0
  43. package/.agent-src/rules/model-recommendation.md +1 -0
  44. package/.agent-src/rules/no-attribution-footers.md +48 -0
  45. package/.agent-src/rules/no-cheap-questions.md +1 -0
  46. package/.agent-src/rules/no-roadmap-references.md +2 -1
  47. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  48. package/.agent-src/rules/onboarding-gate.md +26 -0
  49. package/.agent-src/rules/package-ci-checks.md +1 -0
  50. package/.agent-src/rules/php-coding.md +1 -0
  51. package/.agent-src/rules/preservation-guard.md +1 -0
  52. package/.agent-src/rules/review-routing-awareness.md +1 -0
  53. package/.agent-src/rules/reviewer-awareness.md +1 -0
  54. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  55. package/.agent-src/rules/role-mode-adherence.md +2 -2
  56. package/.agent-src/rules/rule-type-governance.md +1 -0
  57. package/.agent-src/rules/runtime-safety.md +1 -0
  58. package/.agent-src/rules/scope-control.md +1 -0
  59. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  60. package/.agent-src/rules/size-enforcement.md +1 -0
  61. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  62. package/.agent-src/rules/skill-quality.md +50 -0
  63. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  64. package/.agent-src/rules/think-before-action.md +1 -0
  65. package/.agent-src/rules/token-efficiency.md +1 -0
  66. package/.agent-src/rules/tool-safety.md +1 -0
  67. package/.agent-src/rules/ui-audit-gate.md +1 -0
  68. package/.agent-src/rules/upstream-proposal.md +1 -0
  69. package/.agent-src/rules/user-interaction.md +22 -5
  70. package/.agent-src/rules/verify-before-complete.md +1 -0
  71. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  72. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  73. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  74. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  75. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  76. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  77. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  78. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  79. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  80. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  81. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  82. package/.agent-src/templates/agent-settings.md +21 -26
  83. package/.agent-src/templates/roadmaps.md +8 -3
  84. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
  85. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  88. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  89. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  90. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  91. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
  92. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  93. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  94. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  95. package/.agent-src/templates/skill.md +30 -1
  96. package/.claude-plugin/marketplace.json +8 -4
  97. package/AGENTS.md +44 -3
  98. package/CHANGELOG.md +173 -0
  99. package/README.md +22 -22
  100. package/config/agent-settings.template.yml +42 -13
  101. package/config/gitignore-block.txt +4 -4
  102. package/docs/architecture.md +3 -3
  103. package/docs/catalog.md +18 -13
  104. package/docs/contracts/adr-chat-history-split.md +10 -1
  105. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  106. package/docs/contracts/command-clusters.md +1 -1
  107. package/docs/contracts/cross-wing-handoff.md +133 -0
  108. package/docs/contracts/decision-trace-v1.md +146 -0
  109. package/docs/contracts/file-ownership-matrix.json +348 -126
  110. package/docs/contracts/hook-architecture-v1.md +220 -0
  111. package/docs/contracts/memory-visibility-v1.md +122 -0
  112. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  113. package/docs/contracts/rule-interactions.yml +22 -0
  114. package/docs/customization.md +2 -1
  115. package/docs/development.md +4 -1
  116. package/docs/getting-started.md +21 -29
  117. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  118. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  119. package/docs/hook-payload-capture.md +221 -0
  120. package/docs/migrations/commands-1.15.0.md +17 -12
  121. package/docs/skills-catalog.md +5 -4
  122. package/llms.txt +4 -3
  123. package/package.json +1 -1
  124. package/scripts/agent-config +45 -1
  125. package/scripts/ai_council/_default_prices.py +4 -4
  126. package/scripts/ai_council/bundler.py +3 -3
  127. package/scripts/ai_council/clients.py +25 -9
  128. package/scripts/ai_council/modes.py +3 -4
  129. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  130. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  131. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  132. package/scripts/ai_council/pricing.py +10 -9
  133. package/scripts/ai_council/session.py +92 -0
  134. package/scripts/build_rule_trigger_matrix.py +1 -9
  135. package/scripts/capture_showcase_session.py +361 -0
  136. package/scripts/chat_history.py +963 -597
  137. package/scripts/check_always_budget.py +7 -2
  138. package/scripts/check_references.py +12 -2
  139. package/scripts/context_hygiene_hook.py +14 -6
  140. package/scripts/council_cli.py +407 -0
  141. package/scripts/hook_manifest.yaml +217 -0
  142. package/scripts/hooks/__init__.py +1 -0
  143. package/scripts/hooks/augment-chat-history.sh +10 -0
  144. package/scripts/hooks/augment-dispatcher.sh +72 -0
  145. package/scripts/hooks/cline-dispatcher.sh +86 -0
  146. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  147. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  148. package/scripts/hooks/dispatch_hook.py +383 -0
  149. package/scripts/hooks/envelope.py +98 -0
  150. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  151. package/scripts/hooks/state_io.py +122 -0
  152. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  153. package/scripts/hooks_status.py +157 -0
  154. package/scripts/install-hooks.sh +2 -2
  155. package/scripts/install.py +725 -87
  156. package/scripts/install.sh +38 -1
  157. package/scripts/lint_handoffs.py +214 -0
  158. package/scripts/lint_hook_manifest.py +217 -0
  159. package/scripts/lint_one_off_age.py +184 -0
  160. package/scripts/lint_rule_tiers.py +78 -0
  161. package/scripts/lint_showcase_sessions.py +148 -0
  162. package/scripts/minimal_safe_diff_hook.py +245 -0
  163. package/scripts/onboarding_gate_hook.py +13 -8
  164. package/scripts/readme_linter.py +12 -3
  165. package/scripts/redact_hook_capture.py +148 -0
  166. package/scripts/roadmap_progress_hook.py +5 -0
  167. package/scripts/schemas/skill.schema.json +5 -0
  168. package/scripts/skill_linter.py +163 -1
  169. package/scripts/sync_agent_settings.py +32 -129
  170. package/scripts/sync_yaml_rt.py +734 -0
  171. package/scripts/update_prices.py +3 -3
  172. package/scripts/verify_before_complete_hook.py +216 -0
  173. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  174. package/.agent-src/commands/chat-history/clear.md +0 -103
  175. package/.agent-src/commands/chat-history/resume.md +0 -183
  176. package/.agent-src/rules/chat-history-cadence.md +0 -109
  177. package/.agent-src/rules/chat-history-ownership.md +0 -123
  178. package/.agent-src/rules/chat-history-visibility.md +0 -96
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  181. package/scripts/check_phase_coupling.py +0 -148
@@ -0,0 +1,383 @@
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 _maybe_capture_payload(args: argparse.Namespace, payload_text: str) -> None:
171
+ """Write the raw stdin payload to a capture directory when
172
+ ``AGENT_HOOK_CAPTURE_DIR`` is set. Used by the verified-platforms
173
+ discovery roadmap (`agents/roadmaps/road-to-verified-chat-history-platforms.md`)
174
+ to lock real payload shapes before extractor branches are added.
175
+
176
+ Fail-silent: any IO / JSON error must not break dispatch.
177
+ """
178
+ capture_dir = os.environ.get("AGENT_HOOK_CAPTURE_DIR", "").strip()
179
+ if not capture_dir:
180
+ return
181
+ try:
182
+ target = Path(capture_dir).expanduser()
183
+ target.mkdir(parents=True, exist_ok=True)
184
+ try:
185
+ payload = json.loads(payload_text) if payload_text.strip() else {}
186
+ except (ValueError, TypeError):
187
+ payload = {"_raw_text": payload_text}
188
+ record = {
189
+ "captured_at": _now_iso(),
190
+ "platform": args.platform,
191
+ "event": args.event,
192
+ "native_event": args.native_event or "",
193
+ "raw_payload": payload,
194
+ }
195
+ ts = int(time.time() * 1000)
196
+ native = (args.native_event or args.event).replace("/", "_")
197
+ fname = f"{args.platform}__{native}__{ts}__{os.getpid()}.json"
198
+ (target / fname).write_text(
199
+ json.dumps(record, indent=2) + "\n", encoding="utf-8")
200
+ except OSError:
201
+ return
202
+
203
+
204
+ def _build_envelope(args: argparse.Namespace, payload_text: str) -> dict:
205
+ try:
206
+ payload = json.loads(payload_text) if payload_text.strip() else {}
207
+ if not isinstance(payload, dict):
208
+ payload = {"_raw": payload}
209
+ except (ValueError, TypeError):
210
+ payload = {"_raw": payload_text}
211
+ return {
212
+ "schema_version": 1,
213
+ "platform": args.platform,
214
+ "event": args.event,
215
+ "native_event": args.native_event or "",
216
+ "session_id": payload.get("session_id") or os.environ.get("AGENT_SESSION_ID", ""),
217
+ "workspace_root": str(Path.cwd()),
218
+ "payload": payload,
219
+ "settings": {},
220
+ }
221
+
222
+
223
+ def _run_concern(concern: dict, envelope: dict) -> tuple[int, str, str, int]:
224
+ """Invoke one concern with the envelope on stdin.
225
+
226
+ Returns (rc, stderr_text, stdout_text, duration_ms).
227
+
228
+ Concerns run with CWD = consumer workspace (envelope.workspace_root),
229
+ NOT the agent-config package root — concerns resolve `agents/state/`
230
+ and other consumer-local paths relative to CWD. The script *itself*
231
+ lives in the package (REPO_ROOT), so we resolve it absolutely.
232
+ """
233
+ script = REPO_ROOT / concern["script"]
234
+ cmd = [sys.executable, str(script), *(concern.get("args") or [])]
235
+ cmd.extend(["--platform", envelope.get("platform", "generic")])
236
+ workspace = envelope.get("workspace_root") or str(Path.cwd())
237
+ started = time.monotonic()
238
+ try:
239
+ proc = subprocess.run(
240
+ cmd,
241
+ input=json.dumps(envelope),
242
+ capture_output=True,
243
+ text=True,
244
+ cwd=workspace,
245
+ timeout=30,
246
+ check=False,
247
+ )
248
+ except (OSError, subprocess.TimeoutExpired) as exc:
249
+ elapsed = int((time.monotonic() - started) * 1000)
250
+ return (3, f"{concern.get('name')}: {exc}", "", elapsed)
251
+ elapsed = int((time.monotonic() - started) * 1000)
252
+ return (proc.returncode, proc.stderr or "", proc.stdout or "", elapsed)
253
+
254
+
255
+ def _reduce(rcs: list[int]) -> int:
256
+ if any(rc == EXIT_BLOCK for rc in rcs):
257
+ return EXIT_BLOCK
258
+ if any(rc == EXIT_WARN for rc in rcs):
259
+ return EXIT_WARN
260
+ return EXIT_ALLOW
261
+
262
+
263
+ def _write_feedback(envelope: dict, session_id: str, entries: list[dict],
264
+ final_rc: int, started_at: str) -> None:
265
+ """Write per-concern feedback files + summary rollup.
266
+
267
+ Per Council Round 2 (Q1): exit-code reduction collapses the
268
+ severity ladder to a single platform-native code; this dir
269
+ surfaces the per-concern detail to humans / `task hooks-status`.
270
+
271
+ Errors writing feedback are non-fatal — feedback is observability,
272
+ not control flow. We only swallow IO errors here; fail-open
273
+ matches the dispatcher's overall posture.
274
+ """
275
+ workspace = envelope.get("workspace_root") or str(Path.cwd())
276
+ state_root = Path(workspace) / "agents" / "state"
277
+ fb_dir = feedback_dir(state_root, session_id)
278
+ try:
279
+ fb_dir.mkdir(parents=True, exist_ok=True)
280
+ except OSError as exc:
281
+ sys.stderr.write(f"dispatch_hook: feedback dir unavailable: {exc}\n")
282
+ return
283
+ for entry in entries:
284
+ target = fb_dir / f"{entry['concern']}.json"
285
+ try:
286
+ atomic_write_json(target, entry)
287
+ except OSError as exc:
288
+ sys.stderr.write(f"dispatch_hook: feedback write failed for "
289
+ f"{entry['concern']}: {exc}\n")
290
+ summary = {
291
+ "schema_version": 1,
292
+ "session_id": session_id,
293
+ "platform": envelope.get("platform"),
294
+ "event": envelope.get("event"),
295
+ "native_event": envelope.get("native_event") or "",
296
+ "started_at": started_at,
297
+ "completed_at": _now_iso(),
298
+ "final_exit_code": final_rc,
299
+ "final_severity": _severity_for(final_rc),
300
+ "concerns": [
301
+ {k: v for k, v in e.items()
302
+ if k in {"concern", "exit_code", "severity", "decision",
303
+ "reason", "duration_ms"}}
304
+ for e in entries
305
+ ],
306
+ }
307
+ try:
308
+ atomic_write_json(fb_dir / "summary.json", summary)
309
+ except OSError as exc:
310
+ sys.stderr.write(f"dispatch_hook: summary write failed: {exc}\n")
311
+
312
+
313
+ def main(argv: list[str] | None = None) -> int:
314
+ parser = argparse.ArgumentParser(description=__doc__)
315
+ parser.add_argument("--platform", required=True)
316
+ parser.add_argument("--event", required=True)
317
+ parser.add_argument("--native-event", default="")
318
+ parser.add_argument("--manifest", default=str(MANIFEST_PATH))
319
+ parser.add_argument("--dry-run", action="store_true",
320
+ help="Resolve concerns and print plan; do not invoke them.")
321
+ args = parser.parse_args(argv)
322
+
323
+ if args.event not in EVENT_VOCABULARY:
324
+ sys.stderr.write(f"dispatch_hook: unknown event '{args.event}'; allowed: "
325
+ f"{sorted(EVENT_VOCABULARY)}\n")
326
+ return EXIT_ALLOW # fail-open per contract for unknown events
327
+
328
+ manifest_path = Path(args.manifest)
329
+ if not manifest_path.exists():
330
+ sys.stderr.write(f"dispatch_hook: manifest missing at {manifest_path}\n")
331
+ return EXIT_ALLOW
332
+ manifest = _load_yaml(manifest_path)
333
+
334
+ payload_text = "" if sys.stdin.isatty() else sys.stdin.read()
335
+ _maybe_capture_payload(args, payload_text)
336
+ concerns = _resolve_concerns(manifest, args.platform, args.event)
337
+
338
+ if args.dry_run:
339
+ plan = {"platform": args.platform, "event": args.event,
340
+ "concerns": [c["name"] for c in concerns]}
341
+ print(json.dumps(plan, indent=2))
342
+ return EXIT_ALLOW
343
+
344
+ if not concerns:
345
+ return EXIT_ALLOW # platform unsupported / fallback-only / empty slot
346
+
347
+ envelope = _build_envelope(args, payload_text)
348
+ session_id = _resolve_session_id(envelope)
349
+ started_at = _now_iso()
350
+ rcs: list[int] = []
351
+ feedback_entries: list[dict] = []
352
+ for concern in concerns:
353
+ concern_started = _now_iso()
354
+ rc, stderr_text, stdout_text, duration_ms = _run_concern(concern, envelope)
355
+ raw_rc = rc
356
+ if rc >= 3:
357
+ if not concern.get("fail_closed"):
358
+ rc = EXIT_ALLOW # fail-open
359
+ else:
360
+ rc = EXIT_BLOCK
361
+ if stderr_text:
362
+ sys.stderr.write(stderr_text)
363
+ rcs.append(rc)
364
+ reply = _parse_concern_stdout(stdout_text)
365
+ feedback_entries.append({
366
+ "concern": concern["name"],
367
+ "exit_code": rc,
368
+ "raw_exit_code": raw_rc,
369
+ "severity": _severity_for(rc),
370
+ "decision": reply.get("decision") or _severity_for(rc),
371
+ "reason": reply.get("reason"),
372
+ "duration_ms": duration_ms,
373
+ "started_at": concern_started,
374
+ "completed_at": _now_iso(),
375
+ "fail_closed": bool(concern.get("fail_closed")),
376
+ })
377
+ final_rc = _reduce(rcs)
378
+ _write_feedback(envelope, session_id, feedback_entries, final_rc, started_at)
379
+ return final_rc
380
+
381
+
382
+ if __name__ == "__main__":
383
+ 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