@event4u/agent-config 1.18.0 → 1.20.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 (181) hide show
  1. package/.agent-src/commands/agent-handoff.md +14 -10
  2. package/.agent-src/commands/chat-history/import.md +170 -0
  3. package/.agent-src/commands/chat-history/learn.md +178 -0
  4. package/.agent-src/commands/chat-history/show.md +17 -18
  5. package/.agent-src/commands/chat-history.md +26 -25
  6. package/.agent-src/commands/council/default.md +77 -82
  7. package/.agent-src/commands/create-pr.md +28 -8
  8. package/.agent-src/commands/feature/roadmap.md +22 -0
  9. package/.agent-src/commands/roadmap/create.md +38 -6
  10. package/.agent-src/commands/roadmap/execute.md +36 -9
  11. package/.agent-src/commands/sync-gitignore.md +1 -1
  12. package/.agent-src/contexts/communication/rules-auto/skill-quality-mechanics.md +76 -0
  13. package/.agent-src/contexts/communication/rules-auto/slash-command-routing-policy-mechanics.md +3 -3
  14. package/.agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +5 -12
  15. package/.agent-src/rules/agent-authority.md +1 -0
  16. package/.agent-src/rules/agent-docs.md +1 -0
  17. package/.agent-src/rules/analysis-skill-routing.md +1 -0
  18. package/.agent-src/rules/architecture.md +1 -0
  19. package/.agent-src/rules/artifact-drafting-protocol.md +1 -0
  20. package/.agent-src/rules/artifact-engagement-recording.md +1 -0
  21. package/.agent-src/rules/ask-when-uncertain.md +1 -0
  22. package/.agent-src/rules/augment-portability.md +1 -0
  23. package/.agent-src/rules/augment-source-of-truth.md +1 -0
  24. package/.agent-src/rules/autonomous-execution.md +1 -0
  25. package/.agent-src/rules/capture-learnings.md +1 -0
  26. package/.agent-src/rules/cli-output-handling.md +2 -2
  27. package/.agent-src/rules/command-suggestion-policy.md +1 -0
  28. package/.agent-src/rules/commit-conventions.md +1 -0
  29. package/.agent-src/rules/commit-policy.md +1 -0
  30. package/.agent-src/rules/context-hygiene.md +22 -0
  31. package/.agent-src/rules/direct-answers.md +11 -2
  32. package/.agent-src/rules/docker-commands.md +1 -0
  33. package/.agent-src/rules/docs-sync.md +1 -0
  34. package/.agent-src/rules/downstream-changes.md +1 -0
  35. package/.agent-src/rules/e2e-testing.md +1 -0
  36. package/.agent-src/rules/guidelines.md +1 -0
  37. package/.agent-src/rules/improve-before-implement.md +1 -0
  38. package/.agent-src/rules/language-and-tone.md +38 -6
  39. package/.agent-src/rules/laravel-translations.md +1 -0
  40. package/.agent-src/rules/markdown-safe-codeblocks.md +1 -0
  41. package/.agent-src/rules/minimal-safe-diff.md +1 -0
  42. package/.agent-src/rules/missing-tool-handling.md +1 -0
  43. package/.agent-src/rules/model-recommendation.md +1 -0
  44. package/.agent-src/rules/no-attribution-footers.md +48 -0
  45. package/.agent-src/rules/no-cheap-questions.md +1 -0
  46. package/.agent-src/rules/no-roadmap-references.md +2 -1
  47. package/.agent-src/rules/non-destructive-by-default.md +1 -0
  48. package/.agent-src/rules/onboarding-gate.md +26 -0
  49. package/.agent-src/rules/package-ci-checks.md +1 -0
  50. package/.agent-src/rules/php-coding.md +1 -0
  51. package/.agent-src/rules/preservation-guard.md +1 -0
  52. package/.agent-src/rules/review-routing-awareness.md +1 -0
  53. package/.agent-src/rules/reviewer-awareness.md +1 -0
  54. package/.agent-src/rules/roadmap-progress-sync.md +22 -0
  55. package/.agent-src/rules/role-mode-adherence.md +2 -2
  56. package/.agent-src/rules/rule-type-governance.md +1 -0
  57. package/.agent-src/rules/runtime-safety.md +1 -0
  58. package/.agent-src/rules/scope-control.md +1 -0
  59. package/.agent-src/rules/security-sensitive-stop.md +1 -0
  60. package/.agent-src/rules/size-enforcement.md +1 -0
  61. package/.agent-src/rules/skill-improvement-trigger.md +1 -0
  62. package/.agent-src/rules/skill-quality.md +50 -0
  63. package/.agent-src/rules/slash-command-routing-policy.md +39 -0
  64. package/.agent-src/rules/think-before-action.md +1 -0
  65. package/.agent-src/rules/token-efficiency.md +1 -0
  66. package/.agent-src/rules/tool-safety.md +1 -0
  67. package/.agent-src/rules/ui-audit-gate.md +1 -0
  68. package/.agent-src/rules/upstream-proposal.md +1 -0
  69. package/.agent-src/rules/user-interaction.md +22 -5
  70. package/.agent-src/rules/verify-before-complete.md +1 -0
  71. package/.agent-src/skills/ai-council/SKILL.md +4 -5
  72. package/.agent-src/skills/dcf-modeling/SKILL.md +89 -0
  73. package/.agent-src/skills/funnel-analysis/SKILL.md +100 -0
  74. package/.agent-src/skills/md-language-check/SKILL.md +1 -1
  75. package/.agent-src/skills/okr-tree-modeling/SKILL.md +93 -0
  76. package/.agent-src/skills/rice-prioritization/SKILL.md +100 -0
  77. package/.agent-src/skills/roadmap-management/SKILL.md +29 -4
  78. package/.agent-src/skills/subagent-orchestration/SKILL.md +34 -2
  79. package/.agent-src/skills/unit-economics-modeling/SKILL.md +104 -0
  80. package/.agent-src/skills/using-git-worktrees/SKILL.md +1 -0
  81. package/.agent-src/skills/verify-completion-evidence/SKILL.md +8 -1
  82. package/.agent-src/templates/agent-settings.md +21 -26
  83. package/.agent-src/templates/roadmaps.md +8 -3
  84. package/.agent-src/templates/scripts/work_engine/hook_bootstrap.py +16 -5
  85. package/.agent-src/templates/scripts/work_engine/hooks/__init__.py +4 -4
  86. package/.agent-src/templates/scripts/work_engine/hooks/builtin/__init__.py +4 -4
  87. package/.agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.py +7 -51
  88. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.py +1 -2
  89. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.py +1 -2
  90. package/.agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.py +163 -0
  91. package/.agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.py +110 -0
  92. package/.agent-src/templates/scripts/work_engine/hooks/settings.py +36 -0
  93. package/.agent-src/templates/scripts/work_engine/scoring/decision_trace.py +141 -0
  94. package/.agent-src/templates/scripts/work_engine/scoring/memory_visibility.py +125 -0
  95. package/.agent-src/templates/skill.md +30 -1
  96. package/.claude-plugin/marketplace.json +8 -4
  97. package/AGENTS.md +44 -3
  98. package/CHANGELOG.md +173 -0
  99. package/README.md +22 -22
  100. package/config/agent-settings.template.yml +42 -13
  101. package/config/gitignore-block.txt +4 -4
  102. package/docs/architecture.md +3 -3
  103. package/docs/catalog.md +18 -13
  104. package/docs/contracts/adr-chat-history-split.md +10 -1
  105. package/docs/contracts/adr-settings-sync-engine.md +127 -0
  106. package/docs/contracts/command-clusters.md +1 -1
  107. package/docs/contracts/cross-wing-handoff.md +133 -0
  108. package/docs/contracts/decision-trace-v1.md +146 -0
  109. package/docs/contracts/file-ownership-matrix.json +348 -126
  110. package/docs/contracts/hook-architecture-v1.md +220 -0
  111. package/docs/contracts/memory-visibility-v1.md +122 -0
  112. package/docs/contracts/one-off-script-lifecycle.md +109 -0
  113. package/docs/contracts/rule-interactions.yml +22 -0
  114. package/docs/customization.md +2 -1
  115. package/docs/development.md +4 -1
  116. package/docs/getting-started.md +21 -29
  117. package/docs/guidelines/agent-infra/ask-when-uncertain-demos.md +1 -1
  118. package/docs/guidelines/agent-infra/layered-settings.md +32 -13
  119. package/docs/hook-payload-capture.md +221 -0
  120. package/docs/migrations/commands-1.15.0.md +17 -12
  121. package/docs/skills-catalog.md +5 -4
  122. package/llms.txt +4 -3
  123. package/package.json +1 -1
  124. package/scripts/agent-config +45 -1
  125. package/scripts/ai_council/_default_prices.py +4 -4
  126. package/scripts/ai_council/bundler.py +3 -3
  127. package/scripts/ai_council/clients.py +25 -9
  128. package/scripts/ai_council/modes.py +3 -4
  129. package/scripts/ai_council/one_off_archive/2026-05/README.md +22 -0
  130. package/scripts/ai_council/one_off_archive/2026-05/_one_off_roundtrip.py +13 -8
  131. package/scripts/ai_council/one_off_archive/2026-05/_one_off_tier_retrofit.py +180 -0
  132. package/scripts/ai_council/pricing.py +10 -9
  133. package/scripts/ai_council/session.py +92 -0
  134. package/scripts/build_rule_trigger_matrix.py +1 -9
  135. package/scripts/capture_showcase_session.py +361 -0
  136. package/scripts/chat_history.py +963 -597
  137. package/scripts/check_always_budget.py +7 -2
  138. package/scripts/check_references.py +12 -2
  139. package/scripts/context_hygiene_hook.py +14 -6
  140. package/scripts/council_cli.py +407 -0
  141. package/scripts/hook_manifest.yaml +217 -0
  142. package/scripts/hooks/__init__.py +1 -0
  143. package/scripts/hooks/augment-chat-history.sh +10 -0
  144. package/scripts/hooks/augment-dispatcher.sh +72 -0
  145. package/scripts/hooks/cline-dispatcher.sh +86 -0
  146. package/scripts/hooks/cowork-dispatcher.sh +98 -0
  147. package/scripts/hooks/cursor-dispatcher.sh +76 -0
  148. package/scripts/hooks/dispatch_hook.py +383 -0
  149. package/scripts/hooks/envelope.py +98 -0
  150. package/scripts/hooks/gemini-dispatcher.sh +117 -0
  151. package/scripts/hooks/state_io.py +122 -0
  152. package/scripts/hooks/windsurf-dispatcher.sh +123 -0
  153. package/scripts/hooks_status.py +157 -0
  154. package/scripts/install-hooks.sh +2 -2
  155. package/scripts/install.py +725 -87
  156. package/scripts/install.sh +38 -1
  157. package/scripts/lint_handoffs.py +214 -0
  158. package/scripts/lint_hook_manifest.py +217 -0
  159. package/scripts/lint_one_off_age.py +184 -0
  160. package/scripts/lint_rule_tiers.py +78 -0
  161. package/scripts/lint_showcase_sessions.py +148 -0
  162. package/scripts/minimal_safe_diff_hook.py +245 -0
  163. package/scripts/onboarding_gate_hook.py +13 -8
  164. package/scripts/readme_linter.py +12 -3
  165. package/scripts/redact_hook_capture.py +148 -0
  166. package/scripts/roadmap_progress_hook.py +5 -0
  167. package/scripts/schemas/skill.schema.json +5 -0
  168. package/scripts/skill_linter.py +163 -1
  169. package/scripts/sync_agent_settings.py +32 -129
  170. package/scripts/sync_yaml_rt.py +734 -0
  171. package/scripts/update_prices.py +3 -3
  172. package/scripts/verify_before_complete_hook.py +216 -0
  173. package/.agent-src/commands/chat-history/checkpoint.md +0 -126
  174. package/.agent-src/commands/chat-history/clear.md +0 -103
  175. package/.agent-src/commands/chat-history/resume.md +0 -183
  176. package/.agent-src/rules/chat-history-cadence.md +0 -109
  177. package/.agent-src/rules/chat-history-ownership.md +0 -123
  178. package/.agent-src/rules/chat-history-visibility.md +0 -96
  179. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_heartbeat.py +0 -50
  180. package/.agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_turn_check.py +0 -49
  181. package/scripts/check_phase_coupling.py +0 -148
@@ -0,0 +1,734 @@
1
+ """Round-trip YAML layer for ``.agent-settings.yml`` syncs.
2
+
3
+ Self-contained, stdlib-only. Implements a narrow YAML subset with the
4
+ property *user-line preservation*: every line in the user input that
5
+ ``parse`` attaches to a ``Node`` is reproduced character-for-character
6
+ by ``emit``. Synthetic nodes (added by ``merge``) follow the
7
+ template's source formatting.
8
+
9
+ Supported subset
10
+ ================
11
+ * block-mappings, 2- or 4-space indent (no tabs in indent — `ValueError`)
12
+ * mapping values: bare scalars, single-/double-quoted strings, ints,
13
+ bools, ``~`` / ``null`` / ``None`` (kept verbatim, not normalised)
14
+ * block lists (``- foo``) — values verbatim, indent must be consistent
15
+ * inline lists (``[a, b, c]``) — flat only, no nested flow mappings
16
+ * ``#``-comments (full-line and inline) — preserved verbatim
17
+ * blank lines — preserved verbatim
18
+ * CRLF and LF line endings — preserved per-line
19
+ * duplicate keys at the same level: **last wins** (earlier entry is
20
+ replaced, the later line carries the value)
21
+
22
+ Not supported (parser raises ``ValueError`` with a line number):
23
+ anchors (``&`` / ``*``), aliases, nested flow-mappings, ``?``-keys,
24
+ multi-doc (``---`` / ``...``), tagged scalars (``!!str``), multiline
25
+ scalars (``|`` / ``>``), tabs in indent, mixed indent inside a block.
26
+
27
+ Public API
28
+ ==========
29
+ * ``Node`` — round-trip tree node
30
+ * ``parse(text) -> Node`` — Phase 2
31
+ * ``emit(node) -> str`` — Phase 2 (round-trip property)
32
+ * ``merge(user, template) -> Node`` — Phase 3
33
+ * ``heal_user_block(user, template) -> Node`` — Phase 4
34
+ * ``sync(user_text, template_text) -> str`` — Phase 5
35
+ """
36
+ from __future__ import annotations
37
+
38
+ import copy as _copy
39
+ from dataclasses import dataclass, field
40
+
41
+ # --- Public node ----------------------------------------------------
42
+
43
+
44
+ @dataclass
45
+ class Node:
46
+ """A single node in the round-trip YAML tree.
47
+
48
+ ``header_line`` is the ground truth for emit: it is the verbatim
49
+ source line for ``key: value`` (or ``- value`` for list items),
50
+ *including* indent, inline comment, and line ending. Parsed
51
+ fields (``key``, ``raw_value``, ``inline_comment``) are derived
52
+ from ``header_line`` and used by the merger; emit never re-
53
+ serialises them.
54
+
55
+ ``leading`` are the blank / comment lines above the node, also
56
+ verbatim with line endings. ``trailing`` is only meaningful on
57
+ the synthetic root and holds blank / comment lines that follow
58
+ the last top-level child.
59
+ """
60
+
61
+ # Parsed identity (used by merge / heal)
62
+ key: str | None = None
63
+ indent: int = 0
64
+ raw_value: str | None = None
65
+ inline_comment: str | None = None
66
+ is_list_item: bool = False
67
+
68
+ # Verbatim source pieces (used by emit)
69
+ leading: list[str] = field(default_factory=list)
70
+ header_line: str | None = None
71
+ trailing: list[str] = field(default_factory=list)
72
+
73
+ # Tree
74
+ children: list["Node"] = field(default_factory=list)
75
+
76
+ # Provenance
77
+ origin_line: int | None = None
78
+ line_ending: str = "\n"
79
+
80
+
81
+ # --- Tokeniser ------------------------------------------------------
82
+
83
+
84
+ @dataclass
85
+ class _RawLine:
86
+ number: int # 1-based
87
+ raw: str # full line including line ending
88
+ line_ending: str # "\n" or "\r\n" or "" (last line, no terminator)
89
+ body: str # raw without line ending
90
+ indent: int # number of leading spaces
91
+ kind: str # 'blank', 'comment', 'mapping', 'list', 'flow_error'
92
+
93
+
94
+ def _tokenise(text: str) -> list[_RawLine]:
95
+ """Split ``text`` into ``_RawLine`` objects, preserving line endings."""
96
+ out: list[_RawLine] = []
97
+ if text == "":
98
+ return out
99
+ # splitlines(keepends=True) keeps each line's terminator; the last
100
+ # line may have no terminator at all.
101
+ for i, raw in enumerate(text.splitlines(keepends=True), 1):
102
+ if raw.endswith("\r\n"):
103
+ le, body = "\r\n", raw[:-2]
104
+ elif raw.endswith("\n"):
105
+ le, body = "\n", raw[:-1]
106
+ elif raw.endswith("\r"):
107
+ le, body = "\r", raw[:-1]
108
+ else:
109
+ le, body = "", raw
110
+ # Tabs in indent are an error. We compute the leading-whitespace
111
+ # span first, then check it for tabs.
112
+ ws = body[: len(body) - len(body.lstrip(" \t"))]
113
+ if "\t" in ws:
114
+ raise ValueError(
115
+ f"tab character in indent at line {i} (only spaces allowed)"
116
+ )
117
+ indent = len(ws)
118
+ stripped = body.strip()
119
+ if stripped == "":
120
+ kind = "blank"
121
+ elif stripped.startswith("#"):
122
+ kind = "comment"
123
+ elif stripped.startswith("- ") or stripped == "-":
124
+ kind = "list"
125
+ else:
126
+ kind = "mapping"
127
+ out.append(
128
+ _RawLine(
129
+ number=i,
130
+ raw=raw,
131
+ line_ending=le,
132
+ body=body,
133
+ indent=indent,
134
+ kind=kind,
135
+ )
136
+ )
137
+ return out
138
+
139
+
140
+
141
+ # --- Scalar parsing -------------------------------------------------
142
+
143
+
144
+ def _split_inline_comment(value_part: str) -> tuple[str, str | None]:
145
+ """Split ``value # comment`` into ``(value, comment)``.
146
+
147
+ Honours single- and double-quoted string boundaries so a ``#``
148
+ inside a quoted scalar is not treated as a comment delimiter.
149
+ Trailing whitespace between the value and ``#`` is kept on the
150
+ value side so the emitter can reproduce the source exactly via
151
+ ``header_line``; only the parsed-value field strips it.
152
+ """
153
+ in_single = False
154
+ in_double = False
155
+ i = 0
156
+ while i < len(value_part):
157
+ ch = value_part[i]
158
+ if ch == "'" and not in_double:
159
+ in_single = not in_single
160
+ elif ch == '"' and not in_single:
161
+ in_double = not in_double
162
+ elif ch == "#" and not in_single and not in_double:
163
+ # comment starts here; require it to be preceded by space
164
+ # or be at column 0 of the value-part. We're parsing the
165
+ # post-`:` section so column 0 means immediately after
166
+ # `key:` — that case is "no value, only comment".
167
+ if i == 0 or value_part[i - 1] in (" ", "\t"):
168
+ value_text = value_part[:i].rstrip(" \t")
169
+ comment_text = value_part[i:]
170
+ return value_text, comment_text
171
+ i += 1
172
+ return value_part.rstrip(" \t") or "", None
173
+
174
+
175
+ def _parse_mapping_line(body: str) -> tuple[str, str | None, str | None]:
176
+ """Split `` key: value # c`` into ``(key, raw_value, inline_comment)``.
177
+
178
+ ``key`` is the unquoted, parsed identifier (used for merge
179
+ matching). ``raw_value`` is verbatim (including any quotes).
180
+ ``inline_comment`` is the verbatim ``# …`` substring or ``None``.
181
+ The leading indent is stripped before this function is called.
182
+ """
183
+ stripped = body.lstrip(" ")
184
+ # Find the colon that ends the key. Quoted keys may contain ':'.
185
+ if stripped.startswith('"'):
186
+ end = stripped.find('"', 1)
187
+ if end == -1:
188
+ raise ValueError("unterminated double-quoted key")
189
+ raw_key = stripped[: end + 1]
190
+ rest = stripped[end + 1 :]
191
+ key = stripped[1:end]
192
+ elif stripped.startswith("'"):
193
+ end = stripped.find("'", 1)
194
+ if end == -1:
195
+ raise ValueError("unterminated single-quoted key")
196
+ raw_key = stripped[: end + 1]
197
+ rest = stripped[end + 1 :]
198
+ key = stripped[1:end]
199
+ else:
200
+ # bare key — colon is the first ':' that is followed by
201
+ # space, end-of-line, or '#'.
202
+ colon = -1
203
+ for i, ch in enumerate(stripped):
204
+ if ch == ":":
205
+ following = stripped[i + 1 : i + 2]
206
+ if following in ("", " ", "\t", "#"):
207
+ colon = i
208
+ break
209
+ if colon == -1:
210
+ raise ValueError(f"missing ':' in mapping line: {stripped!r}")
211
+ raw_key = stripped[:colon]
212
+ rest = stripped[colon:]
213
+ key = raw_key
214
+ # ``rest`` now starts with ':' (or '"X":' was already consumed
215
+ # for the quoted case and rest starts with whatever follows).
216
+ if rest.startswith(":"):
217
+ rest = rest[1:]
218
+ elif rest.startswith(" :") or rest.startswith("\t:"):
219
+ rest = rest.lstrip(" \t")[1:]
220
+ else:
221
+ # Quoted key followed by `:` — same handling.
222
+ if rest.lstrip().startswith(":"):
223
+ rest = rest.lstrip()[1:]
224
+ else:
225
+ raise ValueError(
226
+ f"missing ':' after key {raw_key!r}: rest={rest!r}"
227
+ )
228
+ # ``rest`` is now whatever follows the colon (may start with space).
229
+ rest_text = rest.lstrip(" \t")
230
+ if rest_text == "":
231
+ return key, None, None
232
+ if rest_text.startswith("#"):
233
+ # Mapping with no value but an inline comment.
234
+ return key, None, rest_text
235
+ raw_value, comment = _split_inline_comment(rest_text)
236
+ return key, raw_value, comment
237
+
238
+
239
+ def _parse_list_line(body: str) -> tuple[str | None, str | None]:
240
+ """``- value # c`` -> ``(raw_value, inline_comment)``.
241
+
242
+ ``-`` alone (no value) is allowed and yields ``(None, None)``.
243
+ """
244
+ stripped = body.lstrip(" ")
245
+ assert stripped == "-" or stripped.startswith("- ")
246
+ if stripped == "-":
247
+ return None, None
248
+ rest = stripped[2:]
249
+ if rest == "":
250
+ return None, None
251
+ if rest.startswith("#"):
252
+ return None, rest
253
+ raw_value, comment = _split_inline_comment(rest)
254
+ return raw_value, comment
255
+
256
+
257
+ # --- Tree builder ---------------------------------------------------
258
+
259
+
260
+ def _build_tree(lines: list[_RawLine]) -> Node:
261
+ """Convert tokenised lines into a ``Node`` tree.
262
+
263
+ Indent state machine:
264
+ * The synthetic root sits at indent -1.
265
+ * Children of the same parent must share an indent.
266
+ * A line with indent > current top of stack opens a child block of
267
+ the previous mapping key (or list item).
268
+ * A line with indent <= current top pops the stack until a parent
269
+ with strictly smaller indent is on top.
270
+ * Trailing blank / comment lines after the last content line
271
+ attach to ``root.trailing``.
272
+ """
273
+ root = Node(indent=-1)
274
+ # stack: list of (parent_node, child_indent_or_None)
275
+ # child_indent is set by the first content child of `parent_node`.
276
+ stack: list[tuple[Node, int | None]] = [(root, None)]
277
+ pending: list[str] = []
278
+
279
+ for line in lines:
280
+ if line.kind in ("blank", "comment"):
281
+ pending.append(line.raw)
282
+ continue
283
+ # Pop until top.indent < current.indent, AND we are not
284
+ # opening a child block of the previous node.
285
+ while True:
286
+ parent, fixed_indent = stack[-1]
287
+ parent_indent = parent.indent
288
+ if line.indent > parent_indent:
289
+ # Going into a child of `parent` — validate consistent
290
+ # child indent.
291
+ if fixed_indent is None:
292
+ # First child sets the child indent.
293
+ stack[-1] = (parent, line.indent)
294
+ break
295
+ if line.indent == fixed_indent:
296
+ break
297
+ if line.indent > fixed_indent:
298
+ raise ValueError(
299
+ f"unexpected over-indent at line {line.number} "
300
+ f"(parent expects {fixed_indent}, got {line.indent})"
301
+ )
302
+ # Less than fixed — pop and re-evaluate.
303
+ stack.pop()
304
+ if not stack:
305
+ raise ValueError(
306
+ f"indent underflow at line {line.number}"
307
+ )
308
+ node = _line_to_node(line)
309
+ # Last-wins for duplicate sibling mapping keys (documented
310
+ # YAML semantics — parser drops the earlier entry).
311
+ if node.key is not None:
312
+ for i, sib in enumerate(parent.children):
313
+ if sib.key == node.key and not sib.is_list_item:
314
+ del parent.children[i]
315
+ break
316
+ parent.children.append(node)
317
+ # Mapping nodes can have children; list items can have children
318
+ # only if a deeper indent follows. Either way push them.
319
+ stack.append((node, None))
320
+ # Re-attach pending leading.
321
+ if pending:
322
+ node.leading = pending
323
+ pending = []
324
+
325
+ # Anything left over after the last content line is root trailing.
326
+ if pending:
327
+ root.trailing = pending
328
+ return root
329
+
330
+
331
+ def _line_to_node(line: _RawLine) -> Node:
332
+ """Convert a content ``_RawLine`` into a ``Node``."""
333
+ if line.kind == "list":
334
+ raw_value, inline = _parse_list_line(line.body)
335
+ return Node(
336
+ key=None,
337
+ indent=line.indent,
338
+ raw_value=raw_value,
339
+ inline_comment=inline,
340
+ is_list_item=True,
341
+ header_line=line.raw,
342
+ origin_line=line.number,
343
+ line_ending=line.line_ending,
344
+ )
345
+ # mapping line
346
+ key, raw_value, inline = _parse_mapping_line(line.body)
347
+ return Node(
348
+ key=key,
349
+ indent=line.indent,
350
+ raw_value=raw_value,
351
+ inline_comment=inline,
352
+ is_list_item=False,
353
+ header_line=line.raw,
354
+ origin_line=line.number,
355
+ line_ending=line.line_ending,
356
+ )
357
+
358
+
359
+ # --- Public parse / emit --------------------------------------------
360
+
361
+
362
+ def parse(text: str) -> Node:
363
+ """Parse YAML ``text`` into a round-trip ``Node`` tree."""
364
+ if text == "":
365
+ return Node(indent=-1)
366
+ return _build_tree(_tokenise(text))
367
+
368
+
369
+ def emit(node: Node) -> str:
370
+ """Emit a ``Node`` tree back to YAML text.
371
+
372
+ For nodes that originated from parsed source, ``header_line`` and
373
+ ``leading`` are reproduced verbatim. Synthetic nodes (added by
374
+ ``merge``) are rendered from their parsed fields using
375
+ template-derived formatting.
376
+ """
377
+ parts: list[str] = []
378
+ _emit_node(node, parts, is_root=True)
379
+ return "".join(parts)
380
+
381
+
382
+ def _emit_node(node: Node, parts: list[str], *, is_root: bool = False) -> None:
383
+ if not is_root:
384
+ parts.extend(node.leading)
385
+ if node.header_line is not None:
386
+ parts.append(node.header_line)
387
+ else:
388
+ parts.append(_render_synthetic_header(node))
389
+ for child in node.children:
390
+ _emit_node(child, parts)
391
+ if is_root:
392
+ parts.extend(node.trailing)
393
+
394
+
395
+ def _render_synthetic_header(node: Node) -> str:
396
+ """Render a synthetic node (no ``header_line``) from parsed fields."""
397
+ indent = " " * node.indent
398
+ le = node.line_ending or "\n"
399
+ if node.is_list_item:
400
+ if node.raw_value is None:
401
+ body = f"{indent}-"
402
+ else:
403
+ body = f"{indent}- {node.raw_value}"
404
+ else:
405
+ if node.raw_value is None:
406
+ body = f"{indent}{node.key}:"
407
+ else:
408
+ body = f"{indent}{node.key}: {node.raw_value}"
409
+ if node.inline_comment:
410
+ body = f"{body} {node.inline_comment}"
411
+ return body + le
412
+
413
+
414
+ # --- Phase 3: additive merger --------------------------------------
415
+
416
+
417
+ def merge(user: Node, template: Node) -> Node:
418
+ """Additive merge of ``template`` into ``user``.
419
+
420
+ Walks the ``template`` tree in order. For every mapping key
421
+ present in ``user`` we recurse into the children. For every
422
+ mapping key **missing** from ``user`` we insert a deep copy of
423
+ the template subtree (verbatim ``header_line`` / ``leading``)
424
+ after the user's copy of the nearest preceding template sibling;
425
+ if no such sibling exists in user, the new node is appended at
426
+ the parent's EOF.
427
+
428
+ Cloned template subtrees adopt the user's predominant line
429
+ ending — a CRLF user file stays CRLF, even when the template
430
+ is LF.
431
+
432
+ Mutates ``user`` in place and returns it.
433
+
434
+ List items are treated as opaque per the Phase 3 spec — a user
435
+ list with content is kept verbatim; a missing list is replaced
436
+ by the template list.
437
+ """
438
+ user_le = _detect_eol(user)
439
+ _merge_into(user, template, is_root=True, user_le=user_le)
440
+ return user
441
+
442
+
443
+ def _merge_into(
444
+ user: Node, template: Node, *, is_root: bool = False, user_le: str = "\n"
445
+ ) -> None:
446
+ user_keys: dict[str, Node] = {
447
+ c.key: c for c in user.children if c.key is not None and not c.is_list_item
448
+ }
449
+ for tmpl_child in template.children:
450
+ if tmpl_child.key is None or tmpl_child.is_list_item:
451
+ continue
452
+ if tmpl_child.key in user_keys:
453
+ user_child = user_keys[tmpl_child.key]
454
+ # Only recurse when:
455
+ # (a) the template child has children (is a section), AND
456
+ # (b) the user child is not an explicit scalar leaf —
457
+ # i.e. ``raw_value`` is None (header-only, ready to
458
+ # receive children) or already has children. A user
459
+ # scalar like ``personal: null`` blocks recursion so
460
+ # we never inject children under a scalar header.
461
+ if tmpl_child.children and (
462
+ user_child.raw_value is None or user_child.children
463
+ ):
464
+ _merge_into(user_child, tmpl_child, user_le=user_le)
465
+ continue
466
+ # Missing — insert a clone of the template subtree.
467
+ cloned = _copy.deepcopy(tmpl_child)
468
+ _normalize_line_endings(cloned, user_le)
469
+ insert_pos = _find_insert_pos(user, template, tmpl_child)
470
+ if is_root:
471
+ # Top-level sections need exactly one blank-line
472
+ # separator from the preceding user content.
473
+ _ensure_blank_separator(cloned)
474
+ user.children.insert(insert_pos, cloned)
475
+ user_keys[tmpl_child.key] = cloned
476
+
477
+
478
+ def _find_insert_pos(user: Node, template: Node, missing: Node) -> int:
479
+ """Index in ``user.children`` for ``missing``.
480
+
481
+ Collects every template sibling that appears *before* ``missing``
482
+ and returns ``max(user_index_of_each) + 1`` so the new node lands
483
+ after the latest preceding-sibling the user file actually
484
+ contains. If none match, returns ``len(user.children)``
485
+ (parent-section EOF).
486
+
487
+ This honours user reordering: when the user reordered ``a, b, c``
488
+ to ``a, c, b`` and the template adds ``d`` after ``c``, ``d`` goes
489
+ after ``b`` (the latest in user order), not after ``c``.
490
+ """
491
+ preceding: set[str] = set()
492
+ for child in template.children:
493
+ if child is missing:
494
+ break
495
+ if child.key is not None and not child.is_list_item:
496
+ preceding.add(child.key)
497
+ last_match = -1
498
+ for i, uc in enumerate(user.children):
499
+ if uc.key in preceding and not uc.is_list_item:
500
+ last_match = i
501
+ if last_match >= 0:
502
+ return last_match + 1
503
+ return len(user.children)
504
+
505
+
506
+ def _ensure_blank_separator(cloned: Node) -> None:
507
+ """Make sure a top-level inserted node starts with one blank line."""
508
+ le = cloned.line_ending or "\n"
509
+ if not cloned.leading or all(line.strip() != "" for line in cloned.leading):
510
+ cloned.leading.insert(0, le)
511
+ else:
512
+ # Collapse runs of leading blanks to a single blank.
513
+ first_blank_seen = False
514
+ kept: list[str] = []
515
+ for line in cloned.leading:
516
+ if line.strip() == "":
517
+ if first_blank_seen:
518
+ continue
519
+ first_blank_seen = True
520
+ kept.append(line)
521
+ else:
522
+ kept.append(line)
523
+ cloned.leading = kept
524
+
525
+
526
+ def _detect_eol(node: Node) -> str:
527
+ """Return the predominant line ending in a parsed tree.
528
+
529
+ Falls back to ``\\n`` when the tree is empty or the count is tied.
530
+ """
531
+ counts = {"\n": 0, "\r\n": 0}
532
+
533
+ def walk(n: Node) -> None:
534
+ if n.line_ending in counts:
535
+ counts[n.line_ending] += 1
536
+ for c in n.children:
537
+ walk(c)
538
+
539
+ walk(node)
540
+ return "\r\n" if counts["\r\n"] > counts["\n"] else "\n"
541
+
542
+
543
+ def _swap_line_ending(line: str, le: str) -> str:
544
+ if line.endswith("\r\n"):
545
+ return line[:-2] + le
546
+ if line.endswith("\n"):
547
+ return line[:-1] + le
548
+ return line # last line of file may have no terminator
549
+
550
+
551
+ def _normalize_line_endings(node: Node, le: str) -> None:
552
+ """Rewrite line endings in a (cloned) subtree to ``le``.
553
+
554
+ Touches ``header_line``, every entry in ``leading`` / ``trailing``,
555
+ and ``line_ending`` itself. Recurses into children.
556
+ """
557
+ if node.header_line is not None:
558
+ node.header_line = _swap_line_ending(node.header_line, le)
559
+ node.leading = [_swap_line_ending(line, le) for line in node.leading]
560
+ node.trailing = [_swap_line_ending(line, le) for line in node.trailing]
561
+ node.line_ending = le
562
+ for child in node.children:
563
+ _normalize_line_endings(child, le)
564
+
565
+
566
+ # --- Phase 4: _user healer -----------------------------------------
567
+
568
+
569
+ def heal_user_block(user: Node, template: Node) -> Node:
570
+ """Heal legacy ``_user._user.foo`` corruption.
571
+
572
+ Walks the top-level ``_user:`` block (if present), collects every
573
+ leaf scalar inside it with `_user` path segments stripped, and:
574
+
575
+ * **Re-homes** leaves whose stripped path exists in the
576
+ ``template`` tree to their template location in ``user``
577
+ (only if the user does not already have a value there —
578
+ existing user values win).
579
+ * **Keeps** leaves with no template home as orphans in a
580
+ rebuilt single-level ``_user:`` block, joining multi-segment
581
+ stripped paths with ``.``.
582
+ * **Drops** the ``_user:`` block entirely when no orphans
583
+ remain after re-homing.
584
+
585
+ Mutates ``user`` in place and returns it. Idempotent — running
586
+ twice yields the same result, which the Phase 5 idempotency
587
+ suite asserts.
588
+ """
589
+ block_idx = next(
590
+ (
591
+ i
592
+ for i, c in enumerate(user.children)
593
+ if c.key == "_user" and not c.is_list_item
594
+ ),
595
+ None,
596
+ )
597
+ if block_idx is None:
598
+ return user
599
+ block = user.children[block_idx]
600
+
601
+ leaves: list[tuple[list[str], Node]] = []
602
+ _collect_leaves(block, [], leaves)
603
+
604
+ orphans: list[tuple[list[str], Node]] = []
605
+ for path, leaf in leaves:
606
+ if not path:
607
+ continue
608
+ if _template_has_path(template, path):
609
+ _rehome_if_missing(user, path, leaf)
610
+ else:
611
+ orphans.append((path, leaf))
612
+
613
+ if orphans:
614
+ rebuilt = Node(
615
+ key="_user",
616
+ indent=block.indent,
617
+ header_line=block.header_line,
618
+ leading=list(block.leading),
619
+ origin_line=block.origin_line,
620
+ line_ending=block.line_ending,
621
+ )
622
+ child_indent = block.indent + 2
623
+ for path, leaf in orphans:
624
+ joined = ".".join(path)
625
+ # ``header_line=None`` lets ``_render_synthetic_header``
626
+ # produce the canonical form — collapses to ``key:`` (no
627
+ # trailing space) when ``raw_value`` is None and avoids
628
+ # the double-space-before-comment failure of a manual
629
+ # f-string.
630
+ rebuilt.children.append(
631
+ Node(
632
+ key=joined,
633
+ indent=child_indent,
634
+ raw_value=leaf.raw_value,
635
+ inline_comment=leaf.inline_comment,
636
+ line_ending=leaf.line_ending,
637
+ )
638
+ )
639
+ user.children[block_idx] = rebuilt
640
+ else:
641
+ user.children.pop(block_idx)
642
+
643
+ return user
644
+
645
+
646
+ def _collect_leaves(
647
+ node: Node,
648
+ path: list[str],
649
+ out: list[tuple[list[str], Node]],
650
+ ) -> None:
651
+ """Recursively collect leaves; ``_user`` segments are stripped.
652
+
653
+ Dotted keys (``_user._user.foo.bar``) are split on ``.`` so each
654
+ component is a separate path segment — this is what lets the
655
+ healer collapse a single corrupted leaf whose key carries N
656
+ leading ``_user.`` prefixes accumulated by the old buggy sync.
657
+ """
658
+ for child in node.children:
659
+ if child.is_list_item or child.key is None:
660
+ continue
661
+ segs = child.key.split(".") if "." in child.key else [child.key]
662
+ stripped = [s for s in segs if s != "_user"]
663
+ next_path = [*path, *stripped]
664
+ if child.children:
665
+ _collect_leaves(child, next_path, out)
666
+ else:
667
+ out.append((next_path, child))
668
+
669
+
670
+ def _template_has_path(template: Node, path: list[str]) -> bool:
671
+ """True iff ``path`` resolves to a node in the template tree."""
672
+ cursor: Node | None = template
673
+ for seg in path:
674
+ if cursor is None:
675
+ return False
676
+ cursor = next(
677
+ (c for c in cursor.children if c.key == seg and not c.is_list_item),
678
+ None,
679
+ )
680
+ return cursor is not None
681
+
682
+
683
+ def _rehome_if_missing(user: Node, path: list[str], leaf: Node) -> None:
684
+ """Insert ``leaf`` at ``path`` in ``user`` if it isn't already there."""
685
+ cursor = user
686
+ for i, seg in enumerate(path):
687
+ existing = next(
688
+ (c for c in cursor.children if c.key == seg and not c.is_list_item),
689
+ None,
690
+ )
691
+ is_last = i == len(path) - 1
692
+ if existing is None:
693
+ indent = cursor.indent + 2 if cursor.key is not None else 0
694
+ # ``header_line=None`` defers rendering to
695
+ # ``_render_synthetic_header`` so empty-value /
696
+ # comment-only headers come out as ``seg:`` and
697
+ # ``seg: # c`` without the manual-f-string drift.
698
+ if is_last:
699
+ cursor.children.append(
700
+ Node(
701
+ key=seg,
702
+ indent=indent,
703
+ raw_value=leaf.raw_value,
704
+ inline_comment=leaf.inline_comment,
705
+ line_ending=leaf.line_ending,
706
+ )
707
+ )
708
+ else:
709
+ container = Node(
710
+ key=seg,
711
+ indent=indent,
712
+ line_ending=leaf.line_ending,
713
+ )
714
+ cursor.children.append(container)
715
+ cursor = container
716
+ else:
717
+ if is_last:
718
+ return # User already has a value here — keep it.
719
+ cursor = existing
720
+
721
+
722
+ def sync(user_text: str, template_text: str) -> str:
723
+ """Top-level sync entry-point.
724
+
725
+ Pipeline: ``parse(user_text) → heal_user_block → merge → emit``.
726
+ The healer runs as a pre-pass so the merger sees a tree that
727
+ already has legacy ``_user._user.foo`` corruption collapsed to
728
+ its template-home or orphan form.
729
+ """
730
+ user_tree = parse(user_text)
731
+ template_tree = parse(template_text)
732
+ user_tree = heal_user_block(user_tree, template_tree)
733
+ merged = merge(user_tree, template_tree)
734
+ return emit(merged)