@event4u/agent-config 1.18.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 (126) 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 +22 -0
  24. package/.agent-src/rules/direct-answers.md +1 -0
  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 +1 -0
  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 +26 -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 +22 -0
  47. package/.agent-src/rules/role-mode-adherence.md +2 -2
  48. package/.agent-src/rules/rule-type-governance.md +1 -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 +8 -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 +62 -0
  77. package/README.md +19 -19
  78. package/config/agent-settings.template.yml +23 -0
  79. package/docs/catalog.md +5 -2
  80. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  81. package/docs/contracts/decision-trace-v1.md +146 -0
  82. package/docs/contracts/file-ownership-matrix.json +7 -0
  83. package/docs/contracts/hook-architecture-v1.md +213 -0
  84. package/docs/contracts/memory-visibility-v1.md +138 -0
  85. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  86. package/docs/contracts/rule-interactions.yml +22 -0
  87. package/docs/customization.md +1 -0
  88. package/docs/development.md +4 -1
  89. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  90. package/package.json +1 -1
  91. package/scripts/agent-config +44 -0
  92. package/scripts/ai_council/bundler.py +3 -3
  93. package/scripts/ai_council/clients.py +24 -8
  94. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  95. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  96. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  97. package/scripts/ai_council/session.py +92 -0
  98. package/scripts/capture_showcase_session.py +361 -0
  99. package/scripts/chat_history.py +11 -1
  100. package/scripts/check_always_budget.py +7 -2
  101. package/scripts/context_hygiene_hook.py +14 -6
  102. package/scripts/council_cli.py +357 -0
  103. package/scripts/hook_manifest.yaml +184 -0
  104. package/scripts/hooks/__init__.py +1 -0
  105. package/scripts/hooks/augment-dispatcher.sh +72 -0
  106. package/scripts/hooks/cline-dispatcher.sh +86 -0
  107. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  108. package/scripts/hooks/dispatch_hook.py +348 -0
  109. package/scripts/hooks/envelope.py +98 -0
  110. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  111. package/scripts/hooks/state_io.py +122 -0
  112. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  113. package/scripts/hooks_status.py +146 -0
  114. package/scripts/install.py +725 -87
  115. package/scripts/install.sh +1 -1
  116. package/scripts/lint_hook_manifest.py +216 -0
  117. package/scripts/lint_one_off_age.py +184 -0
  118. package/scripts/lint_rule_tiers.py +78 -0
  119. package/scripts/lint_showcase_sessions.py +148 -0
  120. package/scripts/minimal_safe_diff_hook.py +245 -0
  121. package/scripts/onboarding_gate_hook.py +13 -8
  122. package/scripts/readme_linter.py +12 -3
  123. package/scripts/roadmap_progress_hook.py +5 -0
  124. package/scripts/sync_agent_settings.py +32 -129
  125. package/scripts/sync_yaml_rt.py +734 -0
  126. package/scripts/verify_before_complete_hook.py +216 -0
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # Cline universal hook trampoline (Phase 7.6, hook-architecture-v1.md).
3
+ #
4
+ # Routes user-scope `~/Documents/Cline/Hooks/<HookName>` events into
5
+ # the active workspace's `./agent-config dispatch:hook`. Project-scope
6
+ # `.clinerules/hooks/<HookName>` does NOT need this trampoline —
7
+ # install.py `ensure_cline_bridge()` writes per-event scripts there
8
+ # that dispatch directly because Cline fires project hooks with the
9
+ # workspace as cwd.
10
+ #
11
+ # Cline event payload (per docs.cline.bot/customization/hooks):
12
+ # { "taskId": "...", "hookName": "...", "clineVersion": "...",
13
+ # "timestamp": "...", "workspaceRoots": ["<path>"], "userId": "...",
14
+ # "model": { ... }, "<hookName-camelCase>": { ... }, ... }
15
+ #
16
+ # Output (read by Cline): JSON `{ cancel: bool, contextModification?,
17
+ # errorMessage? }`. Per agent-config we always emit an empty `{}` —
18
+ # concerns are observe-only at this layer; chat-history /
19
+ # roadmap-progress / context-hygiene never block, and onboarding-gate
20
+ # blocks via state file, not via cancel-on-TaskStart.
21
+ #
22
+ # Phase 7.6 amendment — Windows path guard: Cline upstream issue
23
+ # cline#8073 reports `workspaceRoots` containing CRLF or
24
+ # Windows-style paths on certain hosts. We only act when the first
25
+ # entry is a directory; other shapes silently no-op.
26
+
27
+ set -u
28
+
29
+ # Args from the hook script that wraps this trampoline:
30
+ # $1 = agent-config event name (session_start, post_tool_use, …)
31
+ # $2 = Cline-native hook name (TaskStart, PostToolUse, …)
32
+ EVENT="${1-}"
33
+ NATIVE_EVENT="${2-}"
34
+
35
+ if [ -z "$EVENT" ]; then
36
+ printf '%s\n' '{}'
37
+ exit 0
38
+ fi
39
+
40
+ EVENT_DATA="$(cat)"
41
+
42
+ WORKSPACE=""
43
+ if command -v jq >/dev/null 2>&1; then
44
+ WORKSPACE="$(printf '%s' "$EVENT_DATA" \
45
+ | jq -r '.workspaceRoots[0] // empty' 2>/dev/null)"
46
+ elif command -v python3 >/dev/null 2>&1; then
47
+ WORKSPACE="$(printf '%s' "$EVENT_DATA" | python3 -c '
48
+ import json, sys
49
+ try:
50
+ data = json.load(sys.stdin)
51
+ except Exception:
52
+ sys.exit(0)
53
+ roots = data.get("workspaceRoots") or []
54
+ if roots:
55
+ print(roots[0])
56
+ ' 2>/dev/null)"
57
+ fi
58
+
59
+ # Strip CR (cline#8073 — Windows hosts can emit CRLF) and reject
60
+ # obviously-bogus shapes before the cd.
61
+ WORKSPACE="${WORKSPACE%$'\r'}"
62
+
63
+ if [ -z "$WORKSPACE" ] || [ ! -d "$WORKSPACE" ]; then
64
+ printf '%s\n' '{}'
65
+ exit 0
66
+ fi
67
+
68
+ cd "$WORKSPACE" 2>/dev/null || { printf '%s\n' '{}'; exit 0; }
69
+
70
+ if [ ! -x ./agent-config ]; then
71
+ printf '%s\n' '{}'
72
+ exit 0
73
+ fi
74
+
75
+ printf '%s' "$EVENT_DATA" \
76
+ | ./agent-config dispatch:hook \
77
+ --platform cline \
78
+ --event "$EVENT" \
79
+ --native-event "$NATIVE_EVENT" \
80
+ >/dev/null 2>&1 || true
81
+
82
+ # Cline expects a JSON envelope on stdout; empty object = "no cancel,
83
+ # no context modification, no error". Errors from concerns surface
84
+ # through agents/state/.dispatcher/<session_id>/ per Phase 7.3.
85
+ printf '%s\n' '{}'
86
+ exit 0
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env bash
2
+ # Cursor universal hook trampoline (Phase 7.5, hook-architecture-v1.md).
3
+ #
4
+ # Routes user-scope `~/.cursor/hooks.json` events into the active
5
+ # workspace's `./agent-config dispatch:hook`. Project-scope
6
+ # `.cursor/hooks.json` does NOT need this trampoline — install.py
7
+ # `ensure_cursor_bridge()` writes direct dispatch:hook commands there
8
+ # because the project hooks fire with the workspace as cwd.
9
+ #
10
+ # Cursor event payload (per https://cursor.com/docs/hooks):
11
+ # { "conversation_id": "...", "generation_id": "...",
12
+ # "model": "...", "hook_event_name": "...",
13
+ # "cursor_version": "...", "workspace_roots": ["<path>"],
14
+ # "user_email": "...|null", "transcript_path": "...|null", ... }
15
+ #
16
+ # Behaviour mirrors augment-dispatcher.sh:
17
+ # - Read JSON event from stdin into a buffer.
18
+ # - Extract workspace_roots[0]; bail silently when missing.
19
+ # - cd into that workspace; bail silently when it lacks ./agent-config.
20
+ # - Re-pipe the original JSON into
21
+ # ./agent-config dispatch:hook --platform cursor \
22
+ # --event $1 --native-event $2
23
+ # - Always exit 0 — Cursor's pre-hooks can block via exit code, but
24
+ # none of our concerns block; chat-history / roadmap-progress /
25
+ # context-hygiene are observe-only and onboarding-gate writes
26
+ # state without denying sessionStart.
27
+
28
+ set -u
29
+
30
+ # Args from the platform's hooks.json command string:
31
+ # $1 = agent-config event name (session_start, post_tool_use, …)
32
+ # $2 = Cursor-native event name (sessionStart, postToolUse, …)
33
+ EVENT="${1-}"
34
+ NATIVE_EVENT="${2-}"
35
+
36
+ if [ -z "$EVENT" ]; then
37
+ exit 0
38
+ fi
39
+
40
+ EVENT_DATA="$(cat)"
41
+
42
+ WORKSPACE=""
43
+ if command -v jq >/dev/null 2>&1; then
44
+ WORKSPACE="$(printf '%s' "$EVENT_DATA" \
45
+ | jq -r '.workspace_roots[0] // empty' 2>/dev/null)"
46
+ elif command -v python3 >/dev/null 2>&1; then
47
+ WORKSPACE="$(printf '%s' "$EVENT_DATA" | python3 -c '
48
+ import json, sys
49
+ try:
50
+ data = json.load(sys.stdin)
51
+ except Exception:
52
+ sys.exit(0)
53
+ roots = data.get("workspace_roots") or []
54
+ if roots:
55
+ print(roots[0])
56
+ ' 2>/dev/null)"
57
+ fi
58
+
59
+ if [ -z "$WORKSPACE" ] || [ ! -d "$WORKSPACE" ]; then
60
+ exit 0
61
+ fi
62
+
63
+ cd "$WORKSPACE" 2>/dev/null || exit 0
64
+
65
+ if [ ! -x ./agent-config ]; then
66
+ exit 0
67
+ fi
68
+
69
+ printf '%s' "$EVENT_DATA" \
70
+ | ./agent-config dispatch:hook \
71
+ --platform cursor \
72
+ --event "$EVENT" \
73
+ --native-event "$NATIVE_EVENT" \
74
+ >/dev/null 2>&1 || true
75
+
76
+ exit 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