Comment-preserving config.yml writes
Linear: RIG-3728. Design fork settled in RIG-3999, option A: yaml package
parseDocument plus surgical setIn / deleteIn.
Problem / Intent
Section titled “Problem / Intent”Every settings save (Settings.set, setModelRole, setProjectModelRole) rewrites
config.yml from a plain object through stringifyYamlConfig, so user comments are
structurally absent before the write happens. A setupVersion migration against a
git-tracked, symlinked config lands a comment-stripping diff. Writes must keep every
comment of every key the save did not change, with no change to the file-lock,
source-generation, symlink, quarantine or reader semantics that already exist.
Approach
Section titled “Approach”Parse the exact source text already captured under the write lock
(YamlContentGeneration.source) with yaml’s parseDocument, reconcile the document
toward the object the current code already computes, and serialize with
Document.toString. Readers are untouched: Bun.YAML.parse stays the only parser on
every load path (#loadYamlIfPresent, #loadOverlayYaml, ConfigFile.#parseContent).
The yaml package is a write-side serializer only.
- Reconcile; never re-express mutations.
#saveNowand#saveProjectNowkeep buildingcurrent/projectSettingsexactly as today (setByPath, per-role merge, stale-generation skips, quarantine recovery,#migrateRawSettingsalready applied by#loadYamlIfPresent). The new step takes(source, target)and issues the minimalsetIn/deleteInops so that the document parses totarget. It recurses into maps and sequences and touches only differing leaves. A whole-mapsetInis never issued: measured,setIn(["modelRoles"], map)drops every per-role inline comment, while per-leafsetInkeeps them becauseYAMLMap.addupdatesprev.value.valuein place when both sides are scalars. - Target is normalized through the existing serializer.
target = Bun.YAML.parse(stringifyYamlConfig(next)). This pins today’s on-disk semantics by construction (undefined-valued keys dropped, legacy keys deleted) for one extra in-memory round trip per debounced save. - No-op short-circuit. When
Bun.YAML.parse(source)deep-equalstarget, the original bytes are returned and nothing is written. This is what makes a second migration byte-identical;toStringalone is not byte-stable (measured:[a, b]becomes[ a, b ], 4-space indent becomes 2). - Round-trip guard. The output is re-parsed with
Bun.YAML.parseand must deep-equaltarget.yamlnever validates its own output; Bun does, so the reader’s semantics decide (for example a%YAML 1.1file whereyamlreadsyesastrueand Bun reads the string"yes": the reconcile re-sets the scalar quoted and Bun reads the same value as before). - Unpreservable sources fail closed (RIG-4215, option A). A source that Bun
loaded but
yamlcannot edit safely —doc.errorsnon-empty (duplicate keys: Bun is last-wins,parseDocumentreportsDUPLICATE_KEY, and withuniqueKeys: falsethe CST edits the first pair whiletoJSkeeps the last), anyAliasnode (Bun resolves<<merge keys, YAML 1.2yamldoes not), a non-map root, or a round-trip mismatch — makes the save reject with an error naming the file and the condition. The file is untouched and the pending change is retained for retry by the existing#saveNowcatch path. There is no destructive full-regeneration fallback. - No new disk read. The source is the text already read under
withFileLockfromwritePath.#writeYamlAtomically’s temp + fsync +replaceFileAtomicallypath is unchanged. Paths with no loaded source (new file, quarantined file recovered from memory, legacy migration) keep the full-regenerate path.
What is guaranteed: comments, key order, and the value/style of every untouched node.
What is not: byte-for-byte whitespace of untouched nodes on a write that changes
something else (flow-collection padding, indentSeq, placement of empty {} / []).
Indent width is detected from the source so the common case does not drift.
Alternatives considered
Section titled “Alternatives considered”- RIG-3728 direction 2: harness-owned state in a separate file. Rejected in
RIG-3999:
setupVersionis one of many keys the harness writes (composer.shape, model roles, every/settingsedit); the split would need a second layer and a merge order for every one. - RIG-3999 option B: switch readers to
yaml. Rejected: changes parse semantics (duplicate keys, 1.1 booleans under a directive, merge keys) for every config, overlay andConfigFileconsumer. - Text patching. Rejected: cannot safely handle nested maps, block scalars or quoted keys; the CST already exists.
- Issue
setInfrom#modifiedpaths instead of reconciling. Rejected: duplicates the stale-skip and role-merge logic and misses migration-driven deletions (lastChangelogVersion,compaction.strategy), which existing tests require on the next save. - Destructive fallback with a warning on unpreservable sources. Rejected in RIG-4215: it would remove comments from the git-tracked file this change protects.
Global Constraints
Section titled “Global Constraints”yamldependency:^2.9.1(registry latest at design time; already resolved inbun.lockas a transitive oflint-staged). Add to the rootcatalogand topackages/utilsdependencies; nowhere else.- Loaded lazily (
await import("yaml")) inside the writer so no startup path pays for it;@oh-my-pi/pi-utils’ index re-exportsyaml-config.ts. - Readers never change:
Bun.YAML.parseremains the parser on every load path; noyamlimport outsidepackages/utils/src/yaml-config.ts. stringifyYamlConfigkeeps its name, signature and trailing-space strip. It stays the new-file serializer and the normalizer. The reconciled output is not passed through the/: +$/gmstrip (it would corrupt block scalars whose lines end in:);yamldoes not emit Bun’s trailing-space artifact.toStringoptions:{ lineWidth: 0, indent: <detected, default 2>, flowCollectionPadding: false, doubleQuotedMinMultiLineLength: Infinity }. Every changed string containing a newline is double-quoted, including strings inside newly inserted maps and sequences; untouched block scalars keep their style.- Paths are key arrays, never dot-split: legacy flat keys such as
"dev.autoqa.consent"are single map keys. - Invariant for every write:
Bun.YAML.parse(bytes)deep-equalsBun.YAML.parse(stringifyYamlConfig(next)). - Preserved unchanged:
withFileLock,#resolveYamlWritePathsymlink walk,#quarantineInvalidYamlLocked,yamlGenerationsMatchstale-skip logic, per-role merge in#saveNow,#projectFileSettingsbookkeeping,#migrateFromLegacy,ConfigFile/ keybindings JSON→YAML migrations. - No new public API on
Settings. One new export in@oh-my-pi/pi-utils(yaml-config.ts), same module asstringifyYamlConfig. - Tests: red first, then green (
rule://red-green-testing); assert on parsed objects and the specific comment lines, plus one byte-identity assertion for the no-op case. - CHANGELOG entry under
packages/coding-agent(user-visible behavior).
Task 1 — updateYamlConfigSource in packages/utils/src/yaml-config.ts
Section titled “Task 1 — updateYamlConfigSource in packages/utils/src/yaml-config.ts”Add the reconcile serializer beside stringifyYamlConfig.
Interfaces:
export function stringifyYamlConfig(value: unknown): string; // unchanged
export type YamlConfigUpdate = | { kind: "unchanged" } // source already parses to `next`; write nothing | { kind: "updated"; content: string } | { kind: "unpreservable"; reason: string }; // Bun-valid, but yaml cannot edit it safely
/** * Re-serialize `source` so it parses to the same object as * `stringifyYamlConfig(next)`, keeping comments, key order and scalar styles of * every node that did not change. */export async function updateYamlConfigSource(source: string, next: unknown): Promise<YamlConfigUpdate>;Algorithm:
target = Bun.YAML.parse(stringifyYamlConfig(next));base = Bun.YAML.parse(source) ?? {}.basenot a plain object →unpreservable("root is not a mapping").Bun.deepEquals(base, target)→unchanged.const { parseDocument, visit, isMap, isSeq, isScalar, Scalar, LineCounter } = await import("yaml"); pass{ prettyErrors: false, lineCounter: new LineCounter() }toparseDocument(source, options). Ondoc.errors, returnunpreservablewith onlyerror.codeandlineCounter.linePos(error.pos[0]).line, never the error message, key text, source excerpt or value: config keys and values can be sensitive, and errors reach logs and the UI. AnyAliasfound byvisit→unpreservable("anchors/aliases"). Ifdoc.contentsis null or a scalar whose value is null, replace it withdoc.createNode({}); move any scalar comments todoc.commentBeforebefore replacement. Keep document-level comments. Any other non-map root →unpreservable.reconcile(doc, [], doc.contents, target):- map: for each pair whose key is not a string →
unpreservable; whose key is absent fromtarget→doc.deleteIn([...path, key]). For each[key, value]oftarget:existing = map.get(key, true); both maps → recurse; both sequences → recurse;Bun.deepEquals(existing?.toJSON(), value)→ skip; else set the value. - sequence (positional): index
i < min(len): both maps → recurse; both sequences → recurse; equal → skip; else set the item. Extra source items →doc.deleteInfrom the end; extra target items → set at their index. - When changing an existing scalar to a scalar,
doc.setIn(path, value)updates it in place and keeps its comment; set its type toScalar.QUOTE_DOUBLEif the new string contains\n. For any inserted or replacement map, sequence, or scalar, create the node withdoc.createNode(value), visit all nested scalars and set multiline strings toScalar.QUOTE_DOUBLE, thendoc.setIn(path, node). Do not assign.typeto a raw JS string returned bygetIn.
- map: for each pair whose key is not a string →
indent= width of the first/^( +)[^\s#]/mmatch insource, default 2.out = doc.toString({ lineWidth: 0, indent, flowCollectionPadding: false, doubleQuotedMinMultiLineLength: Infinity }). Re-parse with Bun;!Bun.deepEquals(Bun.YAML.parse(out), target)→unpreservable("round-trip mismatch"). Returnupdated(out).
Tests (packages/utils/test/yaml-config.test.ts, new; red first):
- keeps a file-top comment, a comment above a key, an inline comment after a value, a comment inside a list, and a comment above a nested key when one unrelated leaf changes;
- updates a scalar in place and keeps that pair’s own inline comment;
- adds a nested key under an existing commented map without touching siblings;
- deletes a key absent from
nextand a flat legacy key ("dev.autoqa.consent"); - replaces a map-valued key by a scalar and the reverse (
setInat the parent, no throw); - per-role: changing
modelRoles.defaultkeeps the comment onmodelRoles.advisor; removing one role keeps the others’ comments; - sequence: changing one item keeps the comments on the others; shortening drops the tail; lengthening appends; changing an item inside a nested sequence keeps comments on its unchanged sibling items;
- a changed write to a source whose first line is a top-level key or comment and whose nested keys use 4-space indent retains that indent width;
unchangedwhennextequals the parsed source even though the source uses 4-space indent and[a, b]flow style;unpreservableon duplicate top-level keys (reason gives code and line, never key or either seeded secret value) and on an alias;- comment-only and empty sources, including
---with comments and a null scalar with an inline comment, gain keys and keep the comments; - multiline strings changed in place, added as a key, and nested inside an added map round-trip without a match for the trailing-space strip;
- an
undefined-valued key innextis absent from the output.
Dependency: yaml added to packages/utils/package.json and the root catalog.
Task 2 — Route #saveNow / #saveProjectNow through the source text
Section titled “Task 2 — Route #saveNow / #saveProjectNow through the source text”Interfaces (private, packages/coding-agent/src/config/settings.ts):
// beforeasync #writeYamlAtomically(filePath: string, settings: RawSettings): Promise<void>// afterasync #writeYamlAtomically(filePath: string, settings: RawSettings, baseSource?: string): Promise<void>Body, before the temp file is opened:
let content: string;if (baseSource === undefined) { content = stringifyYamlConfig(settings);} else { const update = await updateYamlConfigSource(baseSource, settings); if (update.kind === "unchanged") return; if (update.kind === "unpreservable") { throw new Error( `Settings config cannot be updated in place: ${filePath}: ${update.reason}; ` + "fix the file or move it aside (the pending change is kept for retry)", ); } content = update.content;}Temp file (wx, 0o600), fsync, replaceFileAtomically: unchanged.
Call sites:
#saveNow:await this.#writeYamlAtomically(writePath, this.#global, yamlSourceForWrite(loaded)).#saveProjectNow: same withprojectSettings.#migrateFromLegacy: unchanged (nobaseSource).
Helper (module-private, beside yamlGenerationsMatch):
function yamlSourceForWrite(loaded: LockedYamlLoadResult): string | undefined// loaded.generation.source when loaded.settings !== null && loaded.generation.kind ===// "content"; undefined otherwise (missing file, quarantined file recovered from memory).Tests (packages/coding-agent/test/settings-manager.test.ts; red first). Fixture
COMMENTED_CONFIG (hand-written string, pre-migration):
# file-top commentsetupVersion: 1 # inline after value
# above keycompaction: strategy: handoff remoteEnabled: false
modelRoles: # above nested key default: anthropic/claude-opus-4 # pinned on purpose advisor: moonshot/kimi-k2
custom: tags: # inside a list - alpha # first item - beta
dev: autoqa: consent: unset- “setupVersion migration keeps every comment and lands the four key changes”: seed
the fixture;
Settings.init;set("composer.shape", "band");markSetupWizardComplete(settings, 2)(setssetupVersionand flushes). Assert all seven comment lines are present verbatim; parsed file hassetupVersion: 2,dev.autoqaConsent: "unset"with nodev.autoqa,compaction.methodOrder: ["handoff", "soft"]with nostrategy/remoteEnabled,composer.shape: "band";custom.tagsandmodelRolesunchanged. - “a second migration is a byte-identical no-op”: continue from the previous state;
fresh
Settings.init;markSetupWizardComplete(settings, 2); file bytes equal the post-first-write bytes. - “keeps per-role comments when one global model role changes”:
setModelRole("default", …)on the fixture; both role comments survive. - “keeps project config comments when a project role is set”: same shape on
.omp/config.ymlviasetProjectModelRole. - “rejects a duplicate-key config without touching it and retains the pending change”:
seed a file with duplicate
searxng.tokenkeys containing distinct fake secrets;set(...);flush()rejects with the error code and line but neither secret nor credential key; bytes unchanged; no.broken-backup;getstill returns the new value.
Atomicity: unchanged mechanism (temp file + fsync + rename), content computed before
the temp file opens; existing write-failure tests cover it. Existing tests that must
stay green without edits: quarantine (.broken-), dangling-symlink chain,
concurrent-external-edit merges, stale-skip, “writes mapping headers without trailing
whitespace and preserves multiline values”, “moves legacy lastChangelogVersion …”.
Task 3 — CHANGELOG
Section titled “Task 3 — CHANGELOG”packages/coding-agent/CHANGELOG.mdunder[Unreleased]/Changed: “Settings saves now preserve comments and key order inconfig.yml, and the values and styles of untouched nodes. Whitespace can normalize when another value changes. A configyamlcannot edit safely (duplicate keys, anchors) rejects the save with an actionable error instead of being rewritten.”
- T1
updateYamlConfigSource+YamlConfigUpdate, unit tests red→green;yamldep added (catalog +packages/utils). - T2
#writeYamlAtomically(…, baseSource?),yamlSourceForWrite, both save paths wired; settings-manager tests red→green; existing write-path tests untouched and green. - T3 CHANGELOG line.
Resolved decisions
Section titled “Resolved decisions”RIG-3999 — write path (DL-519)
Section titled “RIG-3999 — write path (DL-519)”Matt chose option A: write through yaml parseDocument with surgical setIn /
deleteIn. Readers keep their current parser.
RIG-4215 — unpreservable source (DL-520)
Section titled “RIG-4215 — unpreservable source (DL-520)”Matt chose option A: fail closed. A config Bun loads but yaml cannot edit safely
(duplicate keys, anchors/aliases, round-trip mismatch) rejects the save with the file
path and condition. The file stays untouched; the existing catch path retains the
pending change for retry. A user with duplicate keys must repair the file before
setupVersion can persist.
Open Questions
Section titled “Open Questions”OQ2 (non-load-bearing) — formatting drift on untouched nodes
Section titled “OQ2 (non-load-bearing) — formatting drift on untouched nodes”A write that changes something can renormalize whitespace of untouched nodes
(indentSeq, flow padding, empty {} placement). Comments and values are unaffected;
the no-op path returns the original bytes. Deferred: detect indentSeq / padding
from the source if a real file shows an unwanted diff.