@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,245 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Platform-agnostic hook for the `minimal-safe-diff` rule.
|
|
3
|
+
|
|
4
|
+
Pre-edit gate: counts unique files touched in the current turn (or
|
|
5
|
+
session, when the platform lacks a turn-boundary signal) and warns
|
|
6
|
+
when the count exceeds the configured threshold. The hook never
|
|
7
|
+
blocks — it is observability infra. The rule body cites the resulting
|
|
8
|
+
state file when the agent prepares a diff for review.
|
|
9
|
+
|
|
10
|
+
Wired to multiple events via the manifest:
|
|
11
|
+
- session_start / user_prompt_submit → reset turn-scoped counters
|
|
12
|
+
- pre_tool_use → record the planned edit's path before execution
|
|
13
|
+
|
|
14
|
+
Output: `agents/state/minimal-safe-diff.json`
|
|
15
|
+
{
|
|
16
|
+
"schema_version": 1,
|
|
17
|
+
"session_id": "<str>",
|
|
18
|
+
"turn_started_at": "<iso8601|null>",
|
|
19
|
+
"files_touched_this_turn": ["a", "b", ...],
|
|
20
|
+
"count": <int>,
|
|
21
|
+
"threshold": <int>,
|
|
22
|
+
"warning": <bool>,
|
|
23
|
+
"checked_at": "<iso8601>"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
Exit code is always 0.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import argparse
|
|
31
|
+
import datetime as _dt
|
|
32
|
+
import json
|
|
33
|
+
import re
|
|
34
|
+
import sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
38
|
+
from hooks.state_io import atomic_write_json # noqa: E402
|
|
39
|
+
|
|
40
|
+
STATE_FILE = Path("agents") / "state" / "minimal-safe-diff.json"
|
|
41
|
+
SETTINGS_FILE = ".agent-settings.yml"
|
|
42
|
+
DEFAULT_THRESHOLD = 5
|
|
43
|
+
MAX_TRACKED_PATHS = 200 # hard cap to keep the state file bounded
|
|
44
|
+
|
|
45
|
+
# Edit-tool names across platforms whose successful invocation results
|
|
46
|
+
# in a file being modified, created, or deleted. Keep explicit so an
|
|
47
|
+
# unknown tool doesn't trigger a false positive.
|
|
48
|
+
EDIT_TOOLS = frozenset({
|
|
49
|
+
"str-replace-editor", "str_replace_editor", # Augment
|
|
50
|
+
"save-file", "save_file", # Augment
|
|
51
|
+
"remove-files", "remove_files", # Augment
|
|
52
|
+
"Edit", "Write", "MultiEdit", # Claude Code
|
|
53
|
+
"edit_file", "edit-file", # Cursor
|
|
54
|
+
"create_file", "create-file", "delete_file", # variants
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _now() -> str:
|
|
59
|
+
return _dt.datetime.now(_dt.timezone.utc).isoformat(timespec="seconds")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _empty_state(threshold: int) -> dict:
|
|
63
|
+
return {
|
|
64
|
+
"schema_version": 1,
|
|
65
|
+
"session_id": "",
|
|
66
|
+
"turn_started_at": None,
|
|
67
|
+
"files_touched_this_turn": [],
|
|
68
|
+
"count": 0,
|
|
69
|
+
"threshold": threshold,
|
|
70
|
+
"warning": False,
|
|
71
|
+
"checked_at": _now(),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read_threshold(consumer_root: Path) -> int:
|
|
76
|
+
"""Parse `hooks.minimal_safe_diff.threshold` from .agent-settings.yml.
|
|
77
|
+
|
|
78
|
+
Dependency-free YAML scan — we only need a single integer under a
|
|
79
|
+
nested block; pulling pyyaml in for this would be overkill.
|
|
80
|
+
"""
|
|
81
|
+
settings = consumer_root / SETTINGS_FILE
|
|
82
|
+
if not settings.is_file():
|
|
83
|
+
return DEFAULT_THRESHOLD
|
|
84
|
+
try:
|
|
85
|
+
text = settings.read_text(encoding="utf-8")
|
|
86
|
+
except OSError:
|
|
87
|
+
return DEFAULT_THRESHOLD
|
|
88
|
+
|
|
89
|
+
in_hooks = False
|
|
90
|
+
in_msd = False
|
|
91
|
+
for raw in text.splitlines():
|
|
92
|
+
line = raw.rstrip()
|
|
93
|
+
if not line or line.lstrip().startswith("#"):
|
|
94
|
+
continue
|
|
95
|
+
# top-level key resets nested context
|
|
96
|
+
if line and not line.startswith((" ", "\t")):
|
|
97
|
+
in_hooks = re.match(r"^hooks\s*:\s*$", line) is not None
|
|
98
|
+
in_msd = False
|
|
99
|
+
continue
|
|
100
|
+
if in_hooks:
|
|
101
|
+
m = re.match(r"^\s+minimal_safe_diff\s*:\s*$", line)
|
|
102
|
+
if m:
|
|
103
|
+
in_msd = True
|
|
104
|
+
continue
|
|
105
|
+
# leaving the minimal_safe_diff block when indent decreases
|
|
106
|
+
if in_msd and re.match(r"^\s{0,3}\S", line):
|
|
107
|
+
in_msd = False
|
|
108
|
+
if in_msd:
|
|
109
|
+
m = re.match(r"^\s+threshold\s*:\s*(\d+)\s*(?:#.*)?$", line)
|
|
110
|
+
if m:
|
|
111
|
+
try:
|
|
112
|
+
val = int(m.group(1))
|
|
113
|
+
return val if val > 0 else DEFAULT_THRESHOLD
|
|
114
|
+
except ValueError:
|
|
115
|
+
return DEFAULT_THRESHOLD
|
|
116
|
+
return DEFAULT_THRESHOLD
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _load_state(target: Path, threshold: int) -> dict:
|
|
120
|
+
if not target.is_file():
|
|
121
|
+
return _empty_state(threshold)
|
|
122
|
+
try:
|
|
123
|
+
decoded = json.loads(target.read_text(encoding="utf-8"))
|
|
124
|
+
if isinstance(decoded, dict):
|
|
125
|
+
base = _empty_state(threshold)
|
|
126
|
+
base.update(decoded)
|
|
127
|
+
base["threshold"] = threshold # always reflect current setting
|
|
128
|
+
return base
|
|
129
|
+
except (OSError, json.JSONDecodeError):
|
|
130
|
+
pass
|
|
131
|
+
return _empty_state(threshold)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _candidate_paths(payload: dict) -> list[str]:
|
|
135
|
+
out: list[str] = []
|
|
136
|
+
fc = payload.get("file_changes")
|
|
137
|
+
if isinstance(fc, list):
|
|
138
|
+
for entry in fc:
|
|
139
|
+
if isinstance(entry, dict):
|
|
140
|
+
p = entry.get("path")
|
|
141
|
+
if isinstance(p, str) and p:
|
|
142
|
+
out.append(p)
|
|
143
|
+
ti = payload.get("tool_input")
|
|
144
|
+
if isinstance(ti, dict):
|
|
145
|
+
for key in ("path", "file_path", "target_file", "filename"):
|
|
146
|
+
v = ti.get(key)
|
|
147
|
+
if isinstance(v, str) and v:
|
|
148
|
+
out.append(v)
|
|
149
|
+
return out
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _normalize(path: str) -> str:
|
|
154
|
+
return path.lstrip("./").replace("\\", "/")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _reset_turn(state: dict, session_id: str) -> dict:
|
|
158
|
+
state["session_id"] = session_id or state.get("session_id") or ""
|
|
159
|
+
state["turn_started_at"] = _now()
|
|
160
|
+
state["files_touched_this_turn"] = []
|
|
161
|
+
state["count"] = 0
|
|
162
|
+
state["warning"] = False
|
|
163
|
+
return state
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _update(state: dict, event: str, envelope: dict, threshold: int) -> dict:
|
|
167
|
+
session_id = envelope.get("session_id") or state.get("session_id") or ""
|
|
168
|
+
if session_id and session_id != state.get("session_id"):
|
|
169
|
+
state = _reset_turn(state, session_id)
|
|
170
|
+
|
|
171
|
+
payload = envelope.get("payload") or {}
|
|
172
|
+
if not isinstance(payload, dict):
|
|
173
|
+
payload = {}
|
|
174
|
+
|
|
175
|
+
if event in ("session_start", "user_prompt_submit"):
|
|
176
|
+
state = _reset_turn(state, session_id)
|
|
177
|
+
elif event in ("pre_tool_use", "post_tool_use"):
|
|
178
|
+
tool = (payload.get("tool_name") or payload.get("toolName")
|
|
179
|
+
or payload.get("tool"))
|
|
180
|
+
if isinstance(tool, str) and tool in EDIT_TOOLS:
|
|
181
|
+
touched: list[str] = list(state.get("files_touched_this_turn") or [])
|
|
182
|
+
seen = set(touched)
|
|
183
|
+
for raw in _candidate_paths(payload):
|
|
184
|
+
norm = _normalize(raw)
|
|
185
|
+
if norm and norm not in seen:
|
|
186
|
+
seen.add(norm)
|
|
187
|
+
touched.append(norm)
|
|
188
|
+
if len(touched) > MAX_TRACKED_PATHS:
|
|
189
|
+
touched = touched[-MAX_TRACKED_PATHS:]
|
|
190
|
+
state["files_touched_this_turn"] = touched
|
|
191
|
+
state["count"] = len(touched)
|
|
192
|
+
state["warning"] = state["count"] > threshold
|
|
193
|
+
|
|
194
|
+
state["threshold"] = threshold
|
|
195
|
+
state["checked_at"] = _now()
|
|
196
|
+
return state
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
|
|
200
|
+
envelope: dict = {}
|
|
201
|
+
if stdin_text.strip():
|
|
202
|
+
try:
|
|
203
|
+
decoded = json.loads(stdin_text)
|
|
204
|
+
if isinstance(decoded, dict):
|
|
205
|
+
envelope = decoded
|
|
206
|
+
except json.JSONDecodeError:
|
|
207
|
+
envelope = {}
|
|
208
|
+
|
|
209
|
+
event = envelope.get("event") or ""
|
|
210
|
+
threshold = _read_threshold(consumer_root)
|
|
211
|
+
target = consumer_root / STATE_FILE
|
|
212
|
+
state = _load_state(target, threshold)
|
|
213
|
+
state = _update(state, event, envelope, threshold)
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
atomic_write_json(target, state)
|
|
217
|
+
except OSError:
|
|
218
|
+
if verbose:
|
|
219
|
+
print("minimal-safe-diff-hook: state write failed",
|
|
220
|
+
file=sys.stderr)
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
if verbose:
|
|
224
|
+
print(
|
|
225
|
+
f"minimal-safe-diff-hook: event={event} "
|
|
226
|
+
f"count={state.get('count')} threshold={threshold} "
|
|
227
|
+
f"warning={state.get('warning')}",
|
|
228
|
+
file=sys.stderr,
|
|
229
|
+
)
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def main(argv: list[str] | None = None) -> int:
|
|
234
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
235
|
+
parser.add_argument("--platform", default="generic",
|
|
236
|
+
help="informational platform tag")
|
|
237
|
+
parser.add_argument("--verbose", action="store_true",
|
|
238
|
+
help="emit one stderr line per invocation")
|
|
239
|
+
args = parser.parse_args(argv)
|
|
240
|
+
return run(sys.stdin.read(), consumer_root=Path.cwd(),
|
|
241
|
+
verbose=args.verbose)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
if __name__ == "__main__": # pragma: no cover
|
|
245
|
+
sys.exit(main())
|
|
@@ -26,11 +26,16 @@ from __future__ import annotations
|
|
|
26
26
|
|
|
27
27
|
import argparse
|
|
28
28
|
import datetime as _dt
|
|
29
|
-
import json
|
|
30
29
|
import re
|
|
31
30
|
import sys
|
|
32
31
|
from pathlib import Path
|
|
33
32
|
|
|
33
|
+
# Re-use the shared atomic-write helper so concerns honour the single
|
|
34
|
+
# `agents/state/.dispatcher.lock` discipline (hook-architecture-v1.md
|
|
35
|
+
# § Concurrency, Phase 7.4).
|
|
36
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
37
|
+
from hooks.state_io import atomic_write_json # noqa: E402
|
|
38
|
+
|
|
34
39
|
SETTINGS_FILE = ".agent-settings.yml"
|
|
35
40
|
STATE_DIR = Path("agents") / "state"
|
|
36
41
|
STATE_FILE = STATE_DIR / "onboarding-gate.json"
|
|
@@ -79,9 +84,12 @@ def _read_onboarded(settings_path: Path) -> tuple[bool, str]:
|
|
|
79
84
|
|
|
80
85
|
def _write_state(consumer_root: Path, required: bool, reason: str,
|
|
81
86
|
settings_present: bool) -> None:
|
|
82
|
-
"""Write `agents/state/onboarding-gate.json` atomically.
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
"""Write `agents/state/onboarding-gate.json` atomically.
|
|
88
|
+
|
|
89
|
+
Uses the shared `agents/state/.dispatcher.lock` so concurrent
|
|
90
|
+
dispatcher invocations across platforms cannot tear the file
|
|
91
|
+
(hook-architecture-v1.md § Concurrency, Phase 7.4).
|
|
92
|
+
"""
|
|
85
93
|
payload = {
|
|
86
94
|
"required": required,
|
|
87
95
|
"reason": reason,
|
|
@@ -89,10 +97,7 @@ def _write_state(consumer_root: Path, required: bool, reason: str,
|
|
|
89
97
|
timespec="seconds"),
|
|
90
98
|
"settings_present": settings_present,
|
|
91
99
|
}
|
|
92
|
-
|
|
93
|
-
tmp = target.with_suffix(".json.tmp")
|
|
94
|
-
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
|
95
|
-
tmp.replace(target)
|
|
100
|
+
atomic_write_json(consumer_root / STATE_FILE, payload)
|
|
96
101
|
|
|
97
102
|
|
|
98
103
|
def run(*, consumer_root: Path, verbose: bool = False) -> int:
|
package/scripts/readme_linter.py
CHANGED
|
@@ -193,15 +193,24 @@ def _detect_repo_type(root: Path, ctx: RepoContext) -> RepoType:
|
|
|
193
193
|
|
|
194
194
|
|
|
195
195
|
def _extract_taskfile_tasks(root: Path) -> list[str]:
|
|
196
|
+
tasks: list[str] = []
|
|
197
|
+
pattern = r"^\s{2}([\w:-]+):"
|
|
196
198
|
for name in ("Taskfile.yml", "Taskfile.yaml"):
|
|
197
199
|
path = root / name
|
|
198
200
|
if path.exists():
|
|
199
201
|
try:
|
|
200
|
-
|
|
201
|
-
return re.findall(r"^\s{2}([\w_-]+):", text, re.MULTILINE)
|
|
202
|
+
tasks.extend(re.findall(pattern, path.read_text(), re.MULTILINE))
|
|
202
203
|
except OSError:
|
|
203
204
|
pass
|
|
204
|
-
|
|
205
|
+
break
|
|
206
|
+
taskfiles_dir = root / "taskfiles"
|
|
207
|
+
if taskfiles_dir.is_dir():
|
|
208
|
+
for path in sorted(taskfiles_dir.glob("*.yml")):
|
|
209
|
+
try:
|
|
210
|
+
tasks.extend(re.findall(pattern, path.read_text(), re.MULTILINE))
|
|
211
|
+
except OSError:
|
|
212
|
+
pass
|
|
213
|
+
return tasks
|
|
205
214
|
|
|
206
215
|
|
|
207
216
|
def _extract_npm_scripts(root: Path) -> list[str]:
|
|
@@ -114,6 +114,11 @@ def run(stdin_text: str, *, consumer_root: Path, verbose: bool = False) -> int:
|
|
|
114
114
|
except json.JSONDecodeError:
|
|
115
115
|
return 0 # malformed stdin → silent no-op, never block
|
|
116
116
|
|
|
117
|
+
# Unwrap dispatcher envelope (Phase 7.3, hook-architecture-v1.md).
|
|
118
|
+
if all(k in payload for k in ("schema_version", "platform", "event", "payload")):
|
|
119
|
+
inner = payload.get("payload")
|
|
120
|
+
payload = inner if isinstance(inner, dict) else {}
|
|
121
|
+
|
|
117
122
|
tool = payload.get("tool_name") or payload.get("toolName") or payload.get("tool")
|
|
118
123
|
if not isinstance(tool, str) or tool not in WRITE_TOOLS:
|
|
119
124
|
return 0
|
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Sync `.agent-settings.yml` against the template + profile.
|
|
2
|
+
"""Sync `.agent-settings.yml` against the template + profile (additive merge).
|
|
3
3
|
|
|
4
4
|
Applies the section-aware merge rules documented in
|
|
5
5
|
`docs/guidelines/agent-infra/layered-settings.md`:
|
|
6
6
|
|
|
7
|
-
-
|
|
8
|
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
- **User lines are preserved verbatim** — comments, quoting, and key order
|
|
8
|
+
survive every sync. Existing values, custom inline comments, and
|
|
9
|
+
user-chosen ordering are never modified.
|
|
10
|
+
- Missing template keys are inserted (leaf into existing parent section,
|
|
11
|
+
full subtree at EOF for entirely missing top-level sections).
|
|
12
|
+
- Top-level user-only sections (no home in the template) are moved to a
|
|
13
|
+
single-level `_user:` block at the end of the file.
|
|
14
|
+
- The `_user:` block is single-level only — legacy multi-prefix
|
|
15
|
+
corruption (`_user._user.foo`) heals to `foo` on the next sync.
|
|
16
|
+
- Template comment changes on already-existing user keys do **not**
|
|
17
|
+
propagate (existing line untouched is the deal).
|
|
13
18
|
|
|
14
19
|
Idempotent — writing a file that is already in sync is a no-op.
|
|
15
20
|
|
|
@@ -33,7 +38,8 @@ import sys
|
|
|
33
38
|
from pathlib import Path
|
|
34
39
|
|
|
35
40
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
36
|
-
import install as _install # noqa: E402 —
|
|
41
|
+
import install as _install # noqa: E402 — profile parsing + template rendering
|
|
42
|
+
import sync_yaml_rt as _rt # noqa: E402 — additive round-trip merge
|
|
37
43
|
|
|
38
44
|
try:
|
|
39
45
|
import yaml # type: ignore
|
|
@@ -46,126 +52,6 @@ DEFAULT_TEMPLATE = Path(__file__).resolve().parent.parent / "config" / "agent-se
|
|
|
46
52
|
DEFAULT_PROFILE_DIR = Path(__file__).resolve().parent.parent / "config" / "profiles"
|
|
47
53
|
|
|
48
54
|
|
|
49
|
-
def _flatten(data: dict, prefix: str = "") -> dict[str, object]:
|
|
50
|
-
"""Flatten nested dicts to dotted keys — recurses to all leaves.
|
|
51
|
-
|
|
52
|
-
Lists, scalars, and ``None`` are leaves. Dicts are walked and their
|
|
53
|
-
keys folded into the dotted path.
|
|
54
|
-
"""
|
|
55
|
-
out: dict[str, object] = {}
|
|
56
|
-
for key, value in data.items():
|
|
57
|
-
path = f"{prefix}{key}"
|
|
58
|
-
if isinstance(value, dict):
|
|
59
|
-
out.update(_flatten(value, prefix=f"{path}."))
|
|
60
|
-
else:
|
|
61
|
-
out[path] = value
|
|
62
|
-
return out
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
def _as_yaml_value(value: object) -> str | None:
|
|
66
|
-
"""Format *value* as an inline-YAML literal.
|
|
67
|
-
|
|
68
|
-
Returns ``None`` when the value cannot be safely represented as a
|
|
69
|
-
scalar / flow-style sequence (e.g. unsupported types). Callers
|
|
70
|
-
must skip those keys so the template default sticks instead of
|
|
71
|
-
producing malformed YAML.
|
|
72
|
-
"""
|
|
73
|
-
if isinstance(value, bool):
|
|
74
|
-
return "true" if value else "false"
|
|
75
|
-
if isinstance(value, int):
|
|
76
|
-
return str(value)
|
|
77
|
-
if isinstance(value, float):
|
|
78
|
-
return repr(value)
|
|
79
|
-
if value is None:
|
|
80
|
-
return "~"
|
|
81
|
-
if isinstance(value, list):
|
|
82
|
-
items: list[str] = []
|
|
83
|
-
for item in value:
|
|
84
|
-
rendered = _as_yaml_value(item)
|
|
85
|
-
if rendered is None:
|
|
86
|
-
return None
|
|
87
|
-
items.append(rendered)
|
|
88
|
-
return "[" + ", ".join(items) + "]"
|
|
89
|
-
if isinstance(value, str):
|
|
90
|
-
return _install._yaml_scalar(value)
|
|
91
|
-
return None
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def _template_keys(template_body: str) -> set[str]:
|
|
95
|
-
"""Return the set of dotted keys declared by the rendered template."""
|
|
96
|
-
data = yaml.safe_load(template_body) or {}
|
|
97
|
-
if not isinstance(data, dict):
|
|
98
|
-
return set()
|
|
99
|
-
return set(_flatten(data).keys())
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
def _apply_user_values(template_body: str, user_flat: dict[str, object]) -> str:
|
|
103
|
-
"""Overlay every known user value on the rendered template body.
|
|
104
|
-
|
|
105
|
-
Keys whose value cannot be rendered inline (see :func:`_as_yaml_value`)
|
|
106
|
-
are skipped so the template default survives instead of corrupting
|
|
107
|
-
the file.
|
|
108
|
-
"""
|
|
109
|
-
body = template_body
|
|
110
|
-
for dotted, value in user_flat.items():
|
|
111
|
-
rendered = _as_yaml_value(value)
|
|
112
|
-
if rendered is None:
|
|
113
|
-
continue
|
|
114
|
-
body = _install._replace_template_value_raw(body, dotted, rendered)
|
|
115
|
-
return body
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def _append_unknown(body: str, user_flat: dict[str, object], known: set[str]) -> str:
|
|
119
|
-
"""Emit user keys that have no home in the template under `_user:`."""
|
|
120
|
-
unknown = sorted(k for k in user_flat if k not in known)
|
|
121
|
-
if not unknown:
|
|
122
|
-
return body
|
|
123
|
-
lines = [
|
|
124
|
-
"",
|
|
125
|
-
"# Unknown keys preserved by sync_agent_settings.py — review and move",
|
|
126
|
-
"# them into the template or drop them.",
|
|
127
|
-
"_user:",
|
|
128
|
-
]
|
|
129
|
-
for key in unknown:
|
|
130
|
-
rendered = _as_yaml_value(user_flat[key])
|
|
131
|
-
if rendered is None:
|
|
132
|
-
continue
|
|
133
|
-
lines.append(f" {key}: {rendered}")
|
|
134
|
-
suffix = "\n".join(lines) + "\n"
|
|
135
|
-
return body + (suffix if body.endswith("\n") else "\n" + suffix)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def render_target(template_body: str, user_data: dict) -> str:
|
|
139
|
-
"""Return the desired `.agent-settings.yml` body for the given user data.
|
|
140
|
-
|
|
141
|
-
The trailing ``_user:`` block (emitted by :func:`_append_unknown`) is
|
|
142
|
-
already in dotted-key form on every read after the first sync. Re-
|
|
143
|
-
flattening it would prepend another ``_user.`` segment on every run
|
|
144
|
-
and accumulate forever, so we strip the wrapper and merge its
|
|
145
|
-
contents straight into the flat dict.
|
|
146
|
-
"""
|
|
147
|
-
if user_data:
|
|
148
|
-
user_only = user_data.pop("_user", None) if isinstance(user_data, dict) else None
|
|
149
|
-
user_flat = _flatten(user_data)
|
|
150
|
-
if isinstance(user_only, dict):
|
|
151
|
-
for key, value in user_only.items():
|
|
152
|
-
# Dotted keys round-trip verbatim — never re-flatten them.
|
|
153
|
-
if isinstance(key, str):
|
|
154
|
-
# Heal legacy corruption: pre-fix syncs prepended a
|
|
155
|
-
# `_user.` segment per run, so a key may carry an
|
|
156
|
-
# arbitrary number of them. Strip them all back to
|
|
157
|
-
# the original leaf path.
|
|
158
|
-
healed = key
|
|
159
|
-
while healed.startswith("_user."):
|
|
160
|
-
healed = healed[len("_user."):]
|
|
161
|
-
user_flat[healed] = value
|
|
162
|
-
else:
|
|
163
|
-
user_flat = {}
|
|
164
|
-
known = _template_keys(template_body)
|
|
165
|
-
body = _apply_user_values(template_body, user_flat)
|
|
166
|
-
return _append_unknown(body, user_flat, known)
|
|
167
|
-
|
|
168
|
-
|
|
169
55
|
def load_profile(profile_dir: Path, profile: str) -> dict[str, str]:
|
|
170
56
|
profile_source = profile_dir / f"{profile}.ini"
|
|
171
57
|
if not profile_source.is_file():
|
|
@@ -231,10 +117,27 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
231
117
|
except FileNotFoundError as exc:
|
|
232
118
|
print(f"error: {exc}", file=sys.stderr)
|
|
233
119
|
return 2
|
|
120
|
+
except yaml.YAMLError as exc:
|
|
121
|
+
print(f"error: cannot parse {target}: {exc}", file=sys.stderr)
|
|
122
|
+
return 2
|
|
234
123
|
|
|
235
|
-
new_text = render_target(template_body, user_data)
|
|
236
124
|
existing_text = target.read_text(encoding="utf-8") if target.is_file() else ""
|
|
237
125
|
|
|
126
|
+
if existing_text:
|
|
127
|
+
# Additive merge — preserves user lines verbatim, inserts only
|
|
128
|
+
# the template keys the user is missing.
|
|
129
|
+
try:
|
|
130
|
+
new_text = _rt.sync(existing_text, template_body)
|
|
131
|
+
except ValueError as exc:
|
|
132
|
+
print(
|
|
133
|
+
f"error: cannot parse {target}: {exc}",
|
|
134
|
+
file=sys.stderr,
|
|
135
|
+
)
|
|
136
|
+
return 2
|
|
137
|
+
else:
|
|
138
|
+
# First-run / file absent — write the rendered template as-is.
|
|
139
|
+
new_text = template_body
|
|
140
|
+
|
|
238
141
|
if new_text == existing_text:
|
|
239
142
|
if not args.quiet:
|
|
240
143
|
print(f"✅ {target}: already in sync (profile={profile})")
|