@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.
- package/.agent-src/commands/council/default.md +74 -76
- package/.agent-src/commands/feature/roadmap.md +22 -0
- package/.agent-src/commands/roadmap/create.md +38 -6
- package/.agent-src/commands/roadmap/execute.md +36 -9
- package/.agent-src/rules/agent-authority.md +1 -0
- package/.agent-src/rules/agent-docs.md +1 -0
- package/.agent-src/rules/analysis-skill-routing.md +1 -0
- package/.agent-src/rules/architecture.md +1 -0
- package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
- package/.agent-src/rules/artifact-engagement-recording.md +1 -0
- package/.agent-src/rules/ask-when-uncertain.md +1 -0
- package/.agent-src/rules/augment-portability.md +1 -0
- package/.agent-src/rules/augment-source-of-truth.md +1 -0
- package/.agent-src/rules/autonomous-execution.md +1 -0
- package/.agent-src/rules/capture-learnings.md +1 -0
- package/.agent-src/rules/chat-history-cadence.md +34 -0
- package/.agent-src/rules/chat-history-ownership.md +1 -0
- package/.agent-src/rules/chat-history-visibility.md +1 -0
- package/.agent-src/rules/cli-output-handling.md +2 -2
- package/.agent-src/rules/command-suggestion-policy.md +1 -0
- package/.agent-src/rules/commit-conventions.md +1 -0
- package/.agent-src/rules/commit-policy.md +1 -0
- package/.agent-src/rules/context-hygiene.md +22 -0
- package/.agent-src/rules/direct-answers.md +1 -0
- package/.agent-src/rules/docker-commands.md +1 -0
- package/.agent-src/rules/docs-sync.md +1 -0
- package/.agent-src/rules/downstream-changes.md +1 -0
- package/.agent-src/rules/e2e-testing.md +1 -0
- package/.agent-src/rules/guidelines.md +1 -0
- package/.agent-src/rules/improve-before-implement.md +1 -0
- package/.agent-src/rules/language-and-tone.md +1 -0
- package/.agent-src/rules/laravel-translations.md +1 -0
- package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
- package/.agent-src/rules/minimal-safe-diff.md +1 -0
- package/.agent-src/rules/missing-tool-handling.md +1 -0
- package/.agent-src/rules/model-recommendation.md +1 -0
- package/.agent-src/rules/no-cheap-questions.md +1 -0
- package/.agent-src/rules/no-roadmap-references.md +1 -0
- package/.agent-src/rules/non-destructive-by-default.md +1 -0
- package/.agent-src/rules/onboarding-gate.md +26 -0
- package/.agent-src/rules/package-ci-checks.md +1 -0
- package/.agent-src/rules/php-coding.md +1 -0
- package/.agent-src/rules/preservation-guard.md +1 -0
- package/.agent-src/rules/review-routing-awareness.md +1 -0
- package/.agent-src/rules/reviewer-awareness.md +1 -0
- package/.agent-src/rules/roadmap-progress-sync.md +22 -0
- package/.agent-src/rules/role-mode-adherence.md +2 -2
- package/.agent-src/rules/rule-type-governance.md +1 -0
- package/.agent-src/rules/runtime-safety.md +1 -0
- package/.agent-src/rules/scope-control.md +1 -0
- package/.agent-src/rules/security-sensitive-stop.md +1 -0
- package/.agent-src/rules/size-enforcement.md +1 -0
- package/.agent-src/rules/skill-improvement-trigger.md +1 -0
- package/.agent-src/rules/skill-quality.md +1 -0
- package/.agent-src/rules/slash-command-routing-policy.md +39 -0
- package/.agent-src/rules/think-before-action.md +1 -0
- package/.agent-src/rules/token-efficiency.md +1 -0
- package/.agent-src/rules/tool-safety.md +1 -0
- package/.agent-src/rules/ui-audit-gate.md +1 -0
- package/.agent-src/rules/upstream-proposal.md +1 -0
- package/.agent-src/rules/user-interaction.md +1 -0
- package/.agent-src/rules/verify-before-complete.md +1 -0
- package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
- package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
- package/.agent-src/templates/agent-settings.md +16 -0
- package/.agent-src/templates/roadmaps.md +8 -3
- package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +9 -0
- package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
- package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +111 -0
- package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
- package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
- package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +62 -0
- package/README.md +19 -19
- package/config/agent-settings.template.yml +23 -0
- package/docs/catalog.md +5 -2
- package/docs/contracts/adr-settings-sync-engine.md +127 -0
- package/docs/contracts/decision-trace-v1.md +146 -0
- package/docs/contracts/file-ownership-matrix.json +7 -0
- package/docs/contracts/hook-architecture-v1.md +213 -0
- package/docs/contracts/memory-visibility-v1.md +138 -0
- package/docs/contracts/one-off-script-lifecycle.md +109 -0
- package/docs/contracts/rule-interactions.yml +22 -0
- package/docs/customization.md +1 -0
- package/docs/development.md +4 -1
- package/docs/guidelines/agent-infra/layered-settings.md +32 -13
- package/package.json +1 -1
- package/scripts/agent-config +44 -0
- package/scripts/ai_council/bundler.py +3 -3
- package/scripts/ai_council/clients.py +24 -8
- package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
- package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
- package/scripts/ai_council/session.py +92 -0
- package/scripts/capture_showcase_session.py +361 -0
- package/scripts/chat_history.py +11 -1
- package/scripts/check_always_budget.py +7 -2
- package/scripts/context_hygiene_hook.py +14 -6
- package/scripts/council_cli.py +357 -0
- package/scripts/hook_manifest.yaml +184 -0
- package/scripts/hooks/__init__.py +1 -0
- package/scripts/hooks/augment-dispatcher.sh +72 -0
- package/scripts/hooks/cline-dispatcher.sh +86 -0
- package/scripts/hooks/cursor-dispatcher.sh +76 -0
- package/scripts/hooks/dispatch_hook.py +348 -0
- package/scripts/hooks/envelope.py +98 -0
- package/scripts/hooks/gemini-dispatcher.sh +117 -0
- package/scripts/hooks/state_io.py +122 -0
- package/scripts/hooks/windsurf-dispatcher.sh +123 -0
- package/scripts/hooks_status.py +146 -0
- package/scripts/install.py +725 -87
- package/scripts/install.sh +1 -1
- package/scripts/lint_hook_manifest.py +216 -0
- package/scripts/lint_one_off_age.py +184 -0
- package/scripts/lint_rule_tiers.py +78 -0
- package/scripts/lint_showcase_sessions.py +148 -0
- package/scripts/minimal_safe_diff_hook.py +245 -0
- package/scripts/onboarding_gate_hook.py +13 -8
- package/scripts/readme_linter.py +12 -3
- package/scripts/roadmap_progress_hook.py +5 -0
- package/scripts/sync_agent_settings.py +32 -129
- package/scripts/sync_yaml_rt.py +734 -0
- 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
|