Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
1a4b479
fix(#4998): read the decoded milestone scalar, so a quoted milestone …
behruznassre Oct 6, 2026
8250189
chore(#4998): add changeset
behruznassre Oct 6, 2026
f5ae159
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 6, 2026
3629188
test(#4998): pin readStateMilestoneScalar decoding and its three call…
behruznassre Oct 7, 2026
eebe17d
chore(#5246): point the changeset at the split-out issue
behruznassre Oct 7, 2026
accb930
chore(#5246): open the changeset with the bold lead
behruznassre Oct 7, 2026
3075f56
Merge remote-tracking branch 'upstream/next' into fix/4998-state-mile…
behruznassre Oct 7, 2026
43564d5
fix(#4998): record-session leaves a wrapped field whole and inserts i…
behruznassre Oct 7, 2026
f31bc99
chore(#4998): changeset covers both halves of #4998
behruznassre Oct 7, 2026
c741fb4
fix(#4998): every record-session insert path keeps notes and lands ou…
behruznassre Oct 7, 2026
2dd5015
fix(#4998): record-session insert uses the shared fence scanner and n…
behruznassre Oct 7, 2026
1a5ecb8
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 8, 2026
40ca1cd
fix(#4998): fix the four deferred record-session defects
behruznassre Oct 9, 2026
80d1112
fix(#4998): narrow (b) and (d) after Codex review
behruznassre Oct 9, 2026
d839b2e
fix(#4998): carry session values from the body, never from frontmatter
behruznassre Oct 9, 2026
1b4eb62
fix(#4998): refuse U+2028/U+2029 as line breaks, keep fenced examples…
behruznassre Oct 10, 2026
0abb51f
fix(#4998): every session writer and reader skips fenced examples; re…
behruznassre Oct 10, 2026
10a7a6b
fix(#4998): `state patch` writes session fields like `state update`; …
behruznassre Oct 10, 2026
077316a
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 10, 2026
20fe716
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 11, 2026
be44330
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 12, 2026
5d869b8
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 12, 2026
8229640
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 13, 2026
2751733
Merge remote-tracking branch 'upstream/next' into fix/4998-state-mile…
behruznassre Oct 14, 2026
8e431a1
fix(#4998): one line-break set at the planning-document seam; flatten…
behruznassre Oct 14, 2026
3cd8a96
fix(#4998): keep a field's existing U+2028 tail through a renumber; k…
behruznassre Oct 14, 2026
b74be0e
fix(#4998): record-session finds its section past a frontmatter ``` (…
behruznassre Oct 14, 2026
eb065dc
fix(#4998): frontmatter is masked only in a whole document; a CR/U+20…
behruznassre Oct 14, 2026
69b9f3f
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 14, 2026
2f45aef
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 14, 2026
275931b
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 15, 2026
55606e0
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 15, 2026
533af54
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 16, 2026
d63681d
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 16, 2026
2343cc9
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 16, 2026
d4d661a
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 17, 2026
a2d80b9
test(#4998): in-process rows for the in-line break and whole-document…
behruznassre Oct 17, 2026
0633ec5
docs(#4998): name the session fields whose fenced examples are protected
behruznassre Oct 17, 2026
299f405
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 17, 2026
8b39796
fix(#4998): state update/patch write every session field inside the s…
behruznassre Oct 18, 2026
540f21c
Merge remote-tracking branch 'upstream/next' into fix/4998-state-mile…
behruznassre Oct 18, 2026
16fc2a3
fix(#4998): read a session field in session scope only for the writer…
behruznassre Oct 19, 2026
f97afe8
fix(#4998): the post-write check reads session fields with fenced exa…
behruznassre Oct 19, 2026
2b2a172
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 19, 2026
06852b6
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 19, 2026
9303947
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 20, 2026
e8cd29c
Merge remote-tracking branch 'upstream/next' into fix/4998-state-mile…
behruznassre Oct 21, 2026
28e335c
fix(#4998): state patch reports a same-value field updated; pin the s…
behruznassre Oct 21, 2026
92a65a1
fix(#4998): a field record-session wrote in place is not also skipped…
behruznassre Oct 21, 2026
e8cb221
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 21, 2026
3c292b3
fix(#4998): record-session writes the session fields in the session s…
behruznassre Oct 22, 2026
43f1bfa
fix(#4998): a Stopped at already holding the value is not reported bl…
behruznassre Oct 22, 2026
b0e97da
test(#4998): planning-document mutation floor from CI; state-document…
behruznassre Oct 22, 2026
9140d3e
test(#4998): state-document shard budget from the measured CI run
behruznassre Oct 22, 2026
47e1a03
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 22, 2026
b40e1c3
Merge branch 'next' into fix/4998-state-milestone-escaping-session-co…
behruznassre Oct 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sharp-pandas-squeak.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
type: Fixed
pr: 5234
---
**A quoted `STATE.md` milestone no longer gains an escape layer per `state.*` write, and `state record-session` no longer orphans the rest of a wrapped session field** — a milestone containing a literal `"` was copied into `state.json` with one more layer of quotes and backslashes on every write. `record-session` rewrote only the first line of a field whose value wraps; it now leaves such a field whole and lists it under `skipped`. Missing session fields are inserted beside their siblings in the section's own plain or bold form, without rewriting the section, and a frontmatter-only `stopped_at` it replaces is reported in `replacedRecord` (#4998).
36 changes: 27 additions & 9 deletions src/roadmap-parser.cts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, resolvePhaseIdConvention } = planningWorkspace;
import { platformReadSync } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatterMod = require('./frontmatter.cjs');
const { rawFrontmatterField } = frontmatterMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import unusableInputMod = require('./unusable-input.cjs');
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
import { tokenizeHeadings, stripTaggedBlocks, withSection, stripFencedCode, collectSection } from './markdown-sectionizer.cjs';
Expand Down Expand Up @@ -1003,6 +1006,20 @@ function classifyMilestoneScalar(raw: string | null | undefined): { explicitNull
return { explicitNull: false, version: trimmed };
}

/**
* #4998: STATE.md's `milestone:` scalar as the frontmatter parser DECODES it.
* The raw `^milestone:` line read captured the on-disk YAML text — quotes and
* escapes included — and that text was re-serialized as the value, adding one
* escape layer per `state.*` write. Falls back to the legacy line read only
* when there is no parseable frontmatter carrying a string `milestone` key.
*/
function readStateMilestoneScalar(stateRaw: string): string | null {
const field = rawFrontmatterField(stateRaw, 'milestone');
if (field && typeof field.value === 'string') return field.value;
const m = stateRaw.match(/^milestone:\s*(.+)/m);
return m ? m[1] : null;
}

/**
* Extract the current milestone section from ROADMAP.md by positive lookup,
* carrying a `scope` discriminator (ADR-3180 Decision 2) alongside the value.
Expand Down Expand Up @@ -1043,9 +1060,9 @@ function extractCurrentMilestoneScoped(content: string, cwd?: string, ws?: strin
const statePath = path.join(planningDir(cwd, ws), 'STATE.md');
const stateRaw = platformReadSync(statePath);
if (stateRaw !== null) {
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
if (milestoneMatch) {
const classified = classifyMilestoneScalar(milestoneMatch[1]);
const milestoneScalar = readStateMilestoneScalar(stateRaw);
if (milestoneScalar !== null) {
const classified = classifyMilestoneScalar(milestoneScalar);
version = classified.version;
stateAssertsNoMilestone = classified.explicitNull;
}
Expand Down Expand Up @@ -1882,9 +1899,9 @@ function getMilestoneInfo(cwd?: string): { value: MilestoneInfo | null; scope: S
const statePath = path.join(planningDir(cwd), 'STATE.md');
const stateRaw = platformReadSync(statePath);
if (stateRaw !== null) {
const m = stateRaw.match(/^milestone:\s*(.+)/m);
if (m) {
const classified = classifyMilestoneScalar(m[1]);
const milestoneScalar = readStateMilestoneScalar(stateRaw);
if (milestoneScalar !== null) {
const classified = classifyMilestoneScalar(milestoneScalar);
explicitlyNoMilestone = classified.explicitNull;
stateVersion = classified.version;
}
Expand Down Expand Up @@ -2343,9 +2360,9 @@ function currentMilestoneRawRanges(
const statePath = path.join(planningDir(cwd), 'STATE.md');
const stateRaw = platformReadSync(statePath);
if (stateRaw !== null) {
const milestoneMatch = stateRaw.match(/^milestone:\s*(.+)/m);
if (milestoneMatch) {
const classified = classifyMilestoneScalar(milestoneMatch[1]);
const milestoneScalar = readStateMilestoneScalar(stateRaw);
if (milestoneScalar !== null) {
const classified = classifyMilestoneScalar(milestoneScalar);
version = classified.version;
stateAssertsNoMilestone = classified.explicitNull;
}
Expand Down Expand Up @@ -2400,6 +2417,7 @@ export = {
getRoadmapPhaseInternal,
getMilestoneInfo,
classifyMilestoneScalar,
readStateMilestoneScalar,
getMilestonePhaseFilter,
currentMilestoneRawRanges,
withPhaseSection,
Expand Down
51 changes: 42 additions & 9 deletions src/state-document.cts
Original file line number Diff line number Diff line change
Expand Up @@ -377,26 +377,59 @@ export function stateFieldContinuation(content: string, fieldName: string): stri
if (!match) return null;

// `(.+)` stops at the line terminator, so the field's line ends where the
// match does. JS `.` excludes \r as well as \n, so on a CRLF document the \r
// sits just AFTER the match rather than inside it — hence the strip below
// before testing for the newline.
const afterValue = match.index + match[0].length;
// match does.
return continuationAfter(content, match.index + match[0].length);
}

/**
* #4998: the continuation (as `stateFieldContinuation` defines it) of the line
* a single-line field write CHANGED, located from the edit itself — the first
* byte where `before` and `after` differ — so it is always the occurrence the
* writer actually touched, whichever label grammar or occurrence it matched.
* `null` when nothing changed, the edited line is the last line, or it is a
* pipe-table row (a table cell cannot wrap).
*/
export function editedLineContinuation(before: string, after: string): string | null {
if (before === after) return null;
const limit = Math.min(before.length, after.length);
let i = 0;
while (i < limit && before.charCodeAt(i) === after.charCodeAt(i)) i++;
const lineStart = before.lastIndexOf('\n', i - 1) + 1;
if (before.slice(lineStart).trimStart().startsWith('|')) return null;
const lineEnd = before.indexOf('\n', i);
return lineEnd === -1 ? null : continuationAfter(before, lineEnd);
}

/** The continuation lines following the line that ends at `afterValue`. */
function continuationAfter(content: string, afterValue: number): string | null {
// JS `.` excludes \r as well as \n, so on a CRLF document the \r sits just
// AFTER a `(.+)` match rather than inside it — hence the strip below before
// testing for the newline.
const rest = content.slice(afterValue).replace(/^\r/, '');
if (!rest.startsWith('\n')) return null; // end of file: nothing follows

const lines = rest.slice(1).split('\n').map((line) => line.replace(/\r$/, ''));
const continuation: string[] = [];
for (let i = 0; i < lines.length; i++) {
const lines = rest.slice(1).split('\n');
const continuation = lines.slice(0, continuationLineCount(lines)).map((line) => line.replace(/\r$/, '').trim());
return continuation.length ? continuation.join(' ') : null;
}

/**
* How many of `following` — the lines directly below a single-line field —
* continue its value rather than starting the next construct.
*/
export function continuationLineCount(following: string[]): number {
const lines = following.map((line) => line.replace(/\r$/, ''));
let i = 0;
for (; i < lines.length; i++) {
const line = lines[i];
if (!line.trim()) break;
if (MD_STRUCTURE_LINE_RE.test(line)) break;
if (STATE_SIBLING_FIELD_LINE_RE.test(line)) break;
// Look ahead one line: a setext underline below makes THIS line a heading
// title, so stop before consuming it rather than after.
if (i + 1 < lines.length && SETEXT_UNDERLINE_RE.test(lines[i + 1])) break;
continuation.push(line.trim());
}
return continuation.length ? continuation.join(' ') : null;
return i;
}

export function stateExtractField(content: string, fieldName: string): string | null {
Expand Down
Loading
Loading