@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,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)
|