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