@event4u/agent-config 1.18.0 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/.agent-src/commands/council/default.md +74 -76
  2. package/.agent-src/commands/feature/roadmap.md +22 -0
  3. package/.agent-src/commands/roadmap/create.md +38 -6
  4. package/.agent-src/commands/roadmap/execute.md +36 -9
  5. package/.agent-src/rules/agent-authority.md +1 -0
  6. package/.agent-src/rules/agent-docs.md +1 -0
  7. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  8. package/.agent-src/rules/architecture.md +1 -0
  9. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  10. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  11. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  12. package/.agent-src/rules/augment-portability.md +1 -0
  13. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  14. package/.agent-src/rules/autonomous-execution.md +1 -0
  15. package/.agent-src/rules/capture-learnings.md +1 -0
  16. package/.agent-src/rules/chat-history-cadence.md +34 -0
  17. package/.agent-src/rules/chat-history-ownership.md +1 -0
  18. package/.agent-src/rules/chat-history-visibility.md +1 -0
  19. package/.agent-src/rules/cli-output-handling.md +2 -2
  20. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  21. package/.agent-src/rules/commit-conventions.md +1 -0
  22. package/.agent-src/rules/commit-policy.md +1 -0
  23. package/.agent-src/rules/context-hygiene.md +22 -0
  24. package/.agent-src/rules/direct-answers.md +1 -0
  25. package/.agent-src/rules/docker-commands.md +1 -0
  26. package/.agent-src/rules/docs-sync.md +1 -0
  27. package/.agent-src/rules/downstream-changes.md +1 -0
  28. package/.agent-src/rules/e2e-testing.md +1 -0
  29. package/.agent-src/rules/guidelines.md +1 -0
  30. package/.agent-src/rules/improve-before-implement.md +1 -0
  31. package/.agent-src/rules/language-and-tone.md +1 -0
  32. package/.agent-src/rules/laravel-translations.md +1 -0
  33. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  34. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  35. package/.agent-src/rules/missing-tool-handling.md +1 -0
  36. package/.agent-src/rules/model-recommendation.md +1 -0
  37. package/.agent-src/rules/no-cheap-questions.md +1 -0
  38. package/.agent-src/rules/no-roadmap-references.md +1 -0
  39. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  40. package/.agent-src/rules/onboarding-gate.md +26 -0
  41. package/.agent-src/rules/package-ci-checks.md +1 -0
  42. package/.agent-src/rules/php-coding.md +1 -0
  43. package/.agent-src/rules/preservation-guard.md +1 -0
  44. package/.agent-src/rules/review-routing-awareness.md +1 -0
  45. package/.agent-src/rules/reviewer-awareness.md +1 -0
  46. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  47. package/.agent-src/rules/role-mode-adherence.md +2 -2
  48. package/.agent-src/rules/rule-type-governance.md +1 -0
  49. package/.agent-src/rules/runtime-safety.md +1 -0
  50. package/.agent-src/rules/scope-control.md +1 -0
  51. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  52. package/.agent-src/rules/size-enforcement.md +1 -0
  53. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  54. package/.agent-src/rules/skill-quality.md +1 -0
  55. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  56. package/.agent-src/rules/think-before-action.md +1 -0
  57. package/.agent-src/rules/token-efficiency.md +1 -0
  58. package/.agent-src/rules/tool-safety.md +1 -0
  59. package/.agent-src/rules/ui-audit-gate.md +1 -0
  60. package/.agent-src/rules/upstream-proposal.md +1 -0
  61. package/.agent-src/rules/user-interaction.md +1 -0
  62. package/.agent-src/rules/verify-before-complete.md +1 -0
  63. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  64. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  65. package/.agent-src/templates/agent-settings.md +16 -0
  66. package/.agent-src/templates/roadmaps.md +8 -3
  67. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +9 -0
  68. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -0
  69. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -0
  70. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  71. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +111 -0
  72. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  73. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  74. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  75. package/.claude-plugin/marketplace.json +1 -1
  76. package/CHANGELOG.md +62 -0
  77. package/README.md +19 -19
  78. package/config/agent-settings.template.yml +23 -0
  79. package/docs/catalog.md +5 -2
  80. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  81. package/docs/contracts/decision-trace-v1.md +146 -0
  82. package/docs/contracts/file-ownership-matrix.json +7 -0
  83. package/docs/contracts/hook-architecture-v1.md +213 -0
  84. package/docs/contracts/memory-visibility-v1.md +138 -0
  85. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  86. package/docs/contracts/rule-interactions.yml +22 -0
  87. package/docs/customization.md +1 -0
  88. package/docs/development.md +4 -1
  89. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  90. package/package.json +1 -1
  91. package/scripts/agent-config +44 -0
  92. package/scripts/ai_council/bundler.py +3 -3
  93. package/scripts/ai_council/clients.py +24 -8
  94. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  95. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  96. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  97. package/scripts/ai_council/session.py +92 -0
  98. package/scripts/capture_showcase_session.py +361 -0
  99. package/scripts/chat_history.py +11 -1
  100. package/scripts/check_always_budget.py +7 -2
  101. package/scripts/context_hygiene_hook.py +14 -6
  102. package/scripts/council_cli.py +357 -0
  103. package/scripts/hook_manifest.yaml +184 -0
  104. package/scripts/hooks/__init__.py +1 -0
  105. package/scripts/hooks/augment-dispatcher.sh +72 -0
  106. package/scripts/hooks/cline-dispatcher.sh +86 -0
  107. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  108. package/scripts/hooks/dispatch_hook.py +348 -0
  109. package/scripts/hooks/envelope.py +98 -0
  110. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  111. package/scripts/hooks/state_io.py +122 -0
  112. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  113. package/scripts/hooks_status.py +146 -0
  114. package/scripts/install.py +725 -87
  115. package/scripts/install.sh +1 -1
  116. package/scripts/lint_hook_manifest.py +216 -0
  117. package/scripts/lint_one_off_age.py +184 -0
  118. package/scripts/lint_rule_tiers.py +78 -0
  119. package/scripts/lint_showcase_sessions.py +148 -0
  120. package/scripts/minimal_safe_diff_hook.py +245 -0
  121. package/scripts/onboarding_gate_hook.py +13 -8
  122. package/scripts/readme_linter.py +12 -3
  123. package/scripts/roadmap_progress_hook.py +5 -0
  124. package/scripts/sync_agent_settings.py +32 -129
  125. package/scripts/sync_yaml_rt.py +734 -0
  126. package/scripts/verify_before_complete_hook.py +216 -0
@@ -0,0 +1,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
- state_dir = consumer_root / STATE_DIR
84
- state_dir.mkdir(parents=True, exist_ok=True)
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
- target = consumer_root / STATE_FILE
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:
@@ -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
- text = path.read_text()
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
- return []
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
- - Template section order always winsreorder keys to match.
8
- - Existing user scalar values are preserved verbatim (as parsed).
9
- - Missing keys land with their template / profile default.
10
- - Template comments replace user comments in the same position.
11
- - Unknown user keys (not in the template) are preserved in a trailing
12
- `_user:` block so custom additions never get silently dropped.
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 — shares _yaml_scalar + _replace_template_value
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})")