@opengsd/gsd-core 1.19.0 → 1.20.1

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 (131) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/agents/gsd-code-fixer.compact.md +5 -5
  4. package/agents/gsd-code-fixer.md +9 -4
  5. package/agents/gsd-code-reviewer.compact.md +49 -8
  6. package/agents/gsd-code-reviewer.md +53 -14
  7. package/agents/gsd-executor.md +8 -4
  8. package/bin/install.js +36 -40
  9. package/commands/gsd/new-milestone.md +2 -2
  10. package/commands/gsd/new-project.md +2 -2
  11. package/commands/gsd/update.md +5 -2
  12. package/gsd-core/bin/gsd-tools.cjs +177 -93
  13. package/gsd-core/bin/lib/capability-lifecycle.cjs +1 -12
  14. package/gsd-core/bin/lib/capability-registry.cjs +284 -93
  15. package/gsd-core/bin/lib/capability-validator.cjs +15 -0
  16. package/gsd-core/bin/lib/commands.cjs +46 -17
  17. package/gsd-core/bin/lib/config-loader.cjs +3 -3
  18. package/gsd-core/bin/lib/core-utils.cjs +11 -0
  19. package/gsd-core/bin/lib/frontmatter.cjs +21 -3
  20. package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +2 -2
  21. package/gsd-core/bin/lib/gate-context-drift.cjs +1 -1
  22. package/gsd-core/bin/lib/gate-phase-context.cjs +7 -8
  23. package/gsd-core/bin/lib/gate-schema-drift.cjs +1 -1
  24. package/gsd-core/bin/lib/git-info-exclude.cjs +1 -6
  25. package/gsd-core/bin/lib/gsd-tool-dir.cjs +134 -0
  26. package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +13 -9
  27. package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +9 -7
  28. package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +3 -3
  29. package/gsd-core/bin/lib/init.cjs +181 -68
  30. package/gsd-core/bin/lib/install-engine.cjs +9 -9
  31. package/gsd-core/bin/lib/install-manifest-record.cjs +86 -0
  32. package/gsd-core/bin/lib/install-profiles.cjs +4 -2
  33. package/gsd-core/bin/lib/io.cjs +6 -0
  34. package/gsd-core/bin/lib/markdown-sectionizer.cjs +34 -1
  35. package/gsd-core/bin/lib/markdown-table.cjs +23 -0
  36. package/gsd-core/bin/lib/mcp-server.cjs +8 -5
  37. package/gsd-core/bin/lib/milestone.cjs +18 -50
  38. package/gsd-core/bin/lib/phase-entry-grammar.cjs +119 -0
  39. package/gsd-core/bin/lib/phase-id-display.cjs +2 -0
  40. package/gsd-core/bin/lib/phase-id.cjs +627 -36
  41. package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -74
  42. package/gsd-core/bin/lib/phase-locator.cjs +328 -19
  43. package/gsd-core/bin/lib/phase.cjs +3039 -666
  44. package/gsd-core/bin/lib/planning-document.cjs +26 -5
  45. package/gsd-core/bin/lib/planning-inspect.cjs +27 -35
  46. package/gsd-core/bin/lib/planning-snapshot.cjs +12 -7
  47. package/gsd-core/bin/lib/planning-workspace.cjs +3 -3
  48. package/gsd-core/bin/lib/read-only-git-env.cjs +23 -2
  49. package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +2 -2
  50. package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -0
  51. package/gsd-core/bin/lib/review-lane-invocation.cjs +125 -18
  52. package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +11 -9
  53. package/gsd-core/bin/lib/roadmap-parser.cjs +759 -144
  54. package/gsd-core/bin/lib/roadmap-progress.cjs +136 -0
  55. package/gsd-core/bin/lib/roadmap-upgrade.cjs +8 -5
  56. package/gsd-core/bin/lib/roadmap.cjs +358 -106
  57. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -130
  58. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +6 -0
  59. package/gsd-core/bin/lib/shell-command-projection.cjs +36 -9
  60. package/gsd-core/bin/lib/smart-entry.cjs +22 -11
  61. package/gsd-core/bin/lib/state-contract.cjs +11 -7
  62. package/gsd-core/bin/lib/state-document.cjs +332 -23
  63. package/gsd-core/bin/lib/state-transition.cjs +51 -15
  64. package/gsd-core/bin/lib/state.cjs +499 -180
  65. package/gsd-core/bin/lib/template.cjs +2 -2
  66. package/gsd-core/bin/lib/validate.cjs +3 -2
  67. package/gsd-core/bin/lib/vendor/re2js.cjs +863 -577
  68. package/gsd-core/bin/lib/vendor/re2js.d.cts +284 -219
  69. package/gsd-core/bin/lib/verify.cjs +2 -2
  70. package/gsd-core/bin/lib/workstream-inventory.cjs +27 -21
  71. package/gsd-core/bin/lib/worktree-base-ref.cjs +32 -2
  72. package/gsd-core/bin/verify-reapply-patches.cjs +411 -10
  73. package/gsd-core/references/cicd-review-patterns.md +45 -0
  74. package/gsd-core/references/necessity-review.md +48 -0
  75. package/gsd-core/workflows/code-review-fix.md +46 -5
  76. package/gsd-core/workflows/code-review.md +3 -0
  77. package/gsd-core/workflows/execute-phase.md +1 -1
  78. package/gsd-core/workflows/execute-plan.md +1 -1
  79. package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +13 -9
  80. package/gsd-core/workflows/plan-phase.md +1 -1
  81. package/gsd-core/workflows/pr-branch.md +2 -0
  82. package/gsd-core/workflows/reapply-patches.md +104 -0
  83. package/gsd-core/workflows/review.md +54 -141
  84. package/gsd-core/workflows/secure-phase.md +1 -1
  85. package/gsd-core/workflows/settings-advanced.md +57 -9
  86. package/gsd-core/workflows/ship.md +12 -0
  87. package/hooks/dist/gsd-agent-isolation-guard.js +1 -1
  88. package/hooks/dist/gsd-cursor-subagent-start.js +1 -1
  89. package/hooks/dist/gsd-graphify-update.sh +6 -0
  90. package/hooks/dist/gsd-statusline.js +3 -2
  91. package/hooks/dist/gsd-windsurf-pre-write.js +4 -4
  92. package/hooks/dist/gsd-workflow-guard.js +2 -1
  93. package/hooks/dist/gsd-worktree-path-guard.js +4 -4
  94. package/hooks/dist/lib/git-probe.js +16 -0
  95. package/hooks/dist/lib/isolation-sentinel.js +77 -2
  96. package/hooks/gsd-agent-isolation-guard.js +1 -1
  97. package/hooks/gsd-cursor-subagent-start.js +1 -1
  98. package/hooks/gsd-graphify-update.sh +6 -0
  99. package/hooks/gsd-statusline.js +3 -2
  100. package/hooks/gsd-windsurf-pre-write.js +4 -4
  101. package/hooks/gsd-workflow-guard.js +2 -1
  102. package/hooks/gsd-worktree-path-guard.js +4 -4
  103. package/hooks/lib/git-probe.js +16 -0
  104. package/hooks/lib/isolation-sentinel.js +77 -2
  105. package/package.json +3 -3
  106. package/pi/package-agents/gsd-code-fixer.md +9 -4
  107. package/pi/package-agents/gsd-code-reviewer.md +53 -14
  108. package/pi/package-agents/gsd-executor.md +8 -4
  109. package/pi/package-agents/manifest.json +3 -3
  110. package/pi/package-skills/gsd-new-milestone/SKILL.md +1 -1
  111. package/pi/package-skills/gsd-new-project/SKILL.md +1 -1
  112. package/pi/package-skills/gsd-update/SKILL.md +4 -1
  113. package/scripts/changeset/lint.cjs +28 -3
  114. package/scripts/ci-next-health.cjs +246 -23
  115. package/scripts/docs-guard-registry.cjs +2 -0
  116. package/scripts/gen-features.cjs +14 -2
  117. package/scripts/lib/drift-scan.cjs +27 -0
  118. package/scripts/lib/macos-conformance-tier.generated.cjs +10 -0
  119. package/scripts/lib/platform-conformance-tier.generated.cjs +15 -0
  120. package/scripts/lint-phase-enumeration-drift.cjs +8 -4
  121. package/scripts/lint-roadmap-progress-drift.cjs +421 -0
  122. package/scripts/merge-commit-pr.cjs +186 -0
  123. package/scripts/mutation-matrix.cjs +23 -1
  124. package/scripts/release-ancestry.cjs +86 -0
  125. package/scripts/release-pipeline-pr.cjs +92 -0
  126. package/scripts/require-issue-link-policy.cjs +18 -9
  127. package/scripts/sync-next-version.cjs +48 -7
  128. package/skills/gsd-new-milestone/SKILL.md +1 -1
  129. package/skills/gsd-new-project/SKILL.md +1 -1
  130. package/skills/gsd-update/SKILL.md +5 -2
  131. package/vscode/package.json +1 -1
@@ -8,19 +8,28 @@
8
8
  * from the prior hand-written .cjs; only types are added.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.KNOWN_STATUS_PATTERNS = exports.KNOWN_TEMPLATE_DEFAULTS = exports.STATUS_ANCHORED_PATTERNS = exports.STATUS_EXACT_TOKENS = void 0;
11
+ exports.KNOWN_STATUS_PATTERNS = exports.KNOWN_TEMPLATE_DEFAULTS = exports.STATUS_ANCHORED_PATTERNS = exports.STATUS_EXACT_TOKENS = exports.FIELD_LINE_BREAK_RE = void 0;
12
12
  exports.toFiniteNumber = toFiniteNumber;
13
13
  exports.isUnfilledFieldValue = isUnfilledFieldValue;
14
14
  exports.leadingCalendarDate = leadingCalendarDate;
15
15
  exports.isRealCalendarDate = isRealCalendarDate;
16
16
  exports.stateFieldContinuation = stateFieldContinuation;
17
+ exports.editedLineContinuation = editedLineContinuation;
18
+ exports.readableEditedBoldLabel = readableEditedBoldLabel;
19
+ exports.markdownFencedBlocks = markdownFencedBlocks;
20
+ exports.maskFencedLines = maskFencedLines;
21
+ exports.writeOutsideFences = writeOutsideFences;
22
+ exports.readableBoldLabels = readableBoldLabels;
17
23
  exports.fieldContinuationEnd = fieldContinuationEnd;
24
+ exports.insertMissingSessionFields = insertMissingSessionFields;
18
25
  exports.stateExtractField = stateExtractField;
19
26
  exports.stateFieldValue = stateFieldValue;
20
27
  exports.stateCurrentPositionSlice = stateCurrentPositionSlice;
21
28
  exports.stateReplaceField = stateReplaceField;
22
29
  exports.stateReplaceFieldWithFallback = stateReplaceFieldWithFallback;
23
30
  exports.stateReplaceFieldInSession = stateReplaceFieldInSession;
31
+ exports.isSessionHeading = isSessionHeading;
32
+ exports.isSessionContinuityHeading = isSessionContinuityHeading;
24
33
  exports.normalizeStateStatus = normalizeStateStatus;
25
34
  exports.computeProgressPercent = computeProgressPercent;
26
35
  exports.shouldPreserveExistingProgress = shouldPreserveExistingProgress;
@@ -32,6 +41,9 @@ const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
32
41
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
33
42
  const pattern_cjs_1 = require("./pattern.cjs");
34
43
  const planning_document_cjs_1 = require("./planning-document.cjs");
44
+ Object.defineProperty(exports, "FIELD_LINE_BREAK_RE", { enumerable: true, get: function () { return planning_document_cjs_1.FIELD_LINE_BREAK_RE; } });
45
+ const frontmatter_fence_cjs_1 = require("./frontmatter-fence.cjs");
46
+ const state_md_schema_cjs_1 = require("./state-md-schema.cjs");
35
47
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module
36
48
  const planningScopeMod = require("./planning-scope.cjs");
37
49
  const { SCOPE } = planningScopeMod;
@@ -324,8 +336,19 @@ function isRealCalendarDate(year, month, day) {
324
336
  * produce false S009 fires on well-formed content: an indented code block, an
325
337
  * HTML block, and a setext underline (`===`, which the `[-*_]{3,}` rule does not
326
338
  * cover — it only knows `-`, `*` and `_`).
339
+ *
340
+ * #4998: a fence, an ATX heading (an empty `##` included), a blockquote or a
341
+ * thematic break opens with up to three leading spaces, as Markdown allows —
342
+ * such a line directly below a field was read as its continuation, so
343
+ * record-session skipped a field that does not wrap. Four spaces or a tab is
344
+ * the indented-code arm. Other indented structure (an indented bullet) stays a
345
+ * continuation on purpose: it fails toward skipping the field, never toward
346
+ * splitting a note from the field it annotates.
327
347
  */
328
- const MD_STRUCTURE_LINE_RE = /^(?:#{1,6}\s|\||>|```|~~~|[-*_]{3,}\s*$|=+\s*$|[-*+]\s|\d+[.)]\s|\[[^\]]+\]:|<|(?: {4}|\t))/;
348
+ // The backtick fence is spelled `\x60{3}`: a literal run of backticks in a
349
+ // regex literal opens a template string for the source scanners in
350
+ // scripts/ (lint-state-field-drift), which then misread the rest of the file.
351
+ const MD_STRUCTURE_LINE_RE = /^(?:\||>| {0,3}(?:#{1,6}(?:\s|$)|\x60{3}|~~~|>|[-*_]{3,}\s*$)|[-*_]{3,}\s*$|=+\s*$|[-*+]\s|\d+[.)]\s|\[[^\]]+\]:|<|(?: {4}|\t))/;
329
352
  /**
330
353
  * A setext heading's underline — `===` or `---` on its own line. The line ABOVE
331
354
  * one of these is a heading TITLE, which is indistinguishable from prose on its
@@ -334,7 +357,36 @@ const MD_STRUCTURE_LINE_RE = /^(?:#{1,6}\s|\||>|```|~~~|[-*_]{3,}\s*$|=+\s*$|[-*
334
357
  * continuation text (#3696 review round 2).
335
358
  */
336
359
  const SETEXT_UNDERLINE_RE = /^(?:=+|-+)\s*$/;
337
- const STATE_SIBLING_FIELD_LINE_RE = /^\*{0,2}[A-Za-z][A-Za-z0-9 _-]*\*{0,2}:{1,2}\*{0,2}(?:\s|$)/;
360
+ // #4998: a sibling field may be indented as the field above it may be
361
+ // (`stateExtractField`'s bold rung tolerates leading whitespace); up to three
362
+ // spaces, as for MD_STRUCTURE_LINE_RE — four is indented code either way. The
363
+ // label admits `.`, `(`, `)` and `/` as well (`Phase 3.1 status:`,
364
+ // `Next (planned):`): a sibling read as a continuation made record-session
365
+ // refuse a field that does not wrap.
366
+ const STATE_SIBLING_FIELD_LINE_RE = /^ {0,3}\*{0,2}[A-Za-z][A-Za-z0-9 _.()/-]*\*{0,2}:{1,2}\*{0,2}(?:\s|$)/;
367
+ /**
368
+ * #4998 review (trek-e 5638530738, Minor 2): the labels STATE.md defines —
369
+ * every body source the frontmatter sync reads, plus the session and position
370
+ * fields the template writes. Lower-cased.
371
+ */
372
+ const STATE_FIELD_LABELS = new Set([
373
+ ...Object.values(state_md_schema_cjs_1.STATE_FIELD_SCHEMA).flatMap((row) => row.bodySource ?? []),
374
+ 'Last session', 'Last Date', 'Resume file', 'Phase', 'Plan', 'Progress', 'Current focus',
375
+ ].map((label) => label.toLowerCase()));
376
+ /**
377
+ * A plain `words: text` line below a field is a sibling field or a wrapped
378
+ * tail (`records and verified: all good`); its shape cannot tell which. A
379
+ * line is a sibling beyond doubt only when its label is bold or one STATE.md
380
+ * defines. Each caller resolves the ambiguous rest toward its own safe side:
381
+ * the record-session wrap check (`editedLineContinuation`) reads it as a
382
+ * continuation and leaves the field whole; the validator and the writer that
383
+ * removes a continuation (`fieldContinuationEnd`) end the value there, so a
384
+ * sibling is never deleted.
385
+ */
386
+ function isUnambiguousSiblingLine(line) {
387
+ const m = /^ {0,3}(\*\*)?([A-Za-z][A-Za-z0-9 _.()/-]*?)(?:\*\*)?:{1,2}(?:\*\*)?(?:\s|$)/.exec(line);
388
+ return m !== null && (m[1] !== undefined || STATE_FIELD_LABELS.has(m[2].trim().toLowerCase()));
389
+ }
338
390
  /**
339
391
  * Return the prose that FOLLOWS a single-line field but plainly belongs to it —
340
392
  * i.e. the remainder `stateExtractField` silently drops when a writer emits a
@@ -373,17 +425,197 @@ function stateFieldContinuation(content, fieldName) {
373
425
  if (!match)
374
426
  return null;
375
427
  // `(.+)` stops at the line terminator, so the field's line ends where the
376
- // match does. JS `.` excludes \r as well as \n, so on a CRLF document the \r
377
- // sits just AFTER the match rather than inside it — hence the strip below
378
- // before testing for the newline.
379
- const afterValue = match.index + match[0].length;
428
+ // match does.
429
+ return continuationAfter(content, match.index + match[0].length);
430
+ }
431
+ /**
432
+ * #4998: where the line a single-line field write changed starts — found from
433
+ * the edit itself, the first byte where `before` and `after` differ, so it is
434
+ * always the occurrence the writer touched, whichever label grammar or
435
+ * occurrence it matched. The bytes before that point are common to both, so
436
+ * the offset holds in either string. Callers handle `before === after`.
437
+ */
438
+ function editedLineStart(before, after) {
439
+ const limit = Math.min(before.length, after.length);
440
+ let i = 0;
441
+ while (i < limit && before.charCodeAt(i) === after.charCodeAt(i))
442
+ i++;
443
+ // `lastIndexOf` clamps a negative start to 0, so i = 0 would find a
444
+ // newline AT byte 0 and misplace the edited line onto the next one.
445
+ return i === 0 ? 0 : before.lastIndexOf('\n', i - 1) + 1;
446
+ }
447
+ /**
448
+ * #4998: the continuation (as `stateFieldContinuation` defines it) of the line
449
+ * a single-line field write CHANGED (`editedLineStart`). `null` when nothing
450
+ * changed — an identical-value write leaves every byte, the continuation
451
+ * included, where it was — when the edited line is the last line, or when it
452
+ * is a pipe-table row (a table cell cannot wrap).
453
+ */
454
+ function editedLineContinuation(before, after) {
455
+ if (before === after)
456
+ return null;
457
+ const lineStart = editedLineStart(before, after);
458
+ const lineEnd = before.indexOf('\n', lineStart);
459
+ // The edited line only: trimming the rest of the document would let a blank
460
+ // edited line borrow a later table row's pipe.
461
+ const line = before.slice(lineStart, lineEnd === -1 ? before.length : lineEnd).replace(/\r$/, '');
462
+ if (line.trimStart().startsWith('|'))
463
+ return null;
464
+ // A line break INSIDE the edited line (a bare CR, U+2028, U+2029) already
465
+ // wraps the value for the `m`-flag readers, which stop there: the text after
466
+ // it is a continuation, exactly as a following LF line is (Codex review round 3).
467
+ const inner = line.search(planning_document_cjs_1.FIELD_LINE_BREAK_RE);
468
+ if (inner !== -1)
469
+ return line.slice(inner + 1).split(planning_document_cjs_1.FIELD_LINE_BREAK_RE).map((part) => part.trim()).join(' ').trim();
470
+ return lineEnd === -1 ? null : continuationAfter(before, lineEnd, false);
471
+ }
472
+ /**
473
+ * #4998: `after` with the line a single-line write changed respelled from
474
+ * `**Label**:` to `**Label:**` — the spelling `stateExtractField` reads — so a
475
+ * value `state record-session` just wrote reads back into the frontmatter
476
+ * sync. `stateReplaceField` keeps the author's spelling by contract (#5007),
477
+ * and that contract stands for every other caller: this is applied only by
478
+ * the one writer whose lines the sync must read back, and only to the one
479
+ * line it wrote. Widening the reader to `**Label**:` instead was tried and
480
+ * reverted — it made the sync read fenced examples. Any other line is
481
+ * untouched.
482
+ */
483
+ function readableEditedBoldLabel(before, after) {
484
+ if (before === after)
485
+ return after;
486
+ const lineStart = editedLineStart(before, after);
487
+ const lineEnd = after.indexOf('\n', lineStart);
488
+ const end = lineEnd === -1 ? after.length : lineEnd;
489
+ const line = after.slice(lineStart, end).replace(/^([ \t]*)\*\*([^*\r\n]+)\*\*:/, '$1**$2:**');
490
+ return after.slice(0, lineStart) + line + after.slice(end);
491
+ }
492
+ /**
493
+ * Index of the closing `---` of a closed frontmatter block at the top of a
494
+ * whole document, or -1 (always -1 outside `document` scope).
495
+ */
496
+ function lastFrontmatterLine(lines, scope) {
497
+ if (!scope.document)
498
+ return -1;
499
+ const text = lines.join('\n');
500
+ const fm = (0, frontmatter_fence_cjs_1.locateFrontmatterFence)(text);
501
+ return fm && fm.closed ? text.slice(0, fm.closingStart).split('\n').length - 1 : -1;
502
+ }
503
+ /**
504
+ * #4998: `scanFencedBlocks` over the Markdown of `lines` only. In a whole
505
+ * document the frontmatter block is YAML, where a ``` line is data (a block
506
+ * scalar), not a fence; its lines are blanked for the scan, so they never open
507
+ * a fence that shifts every boundary after them (Codex review). Line indices
508
+ * are those of `lines`.
509
+ */
510
+ function markdownFencedBlocks(lines, scope = {}) {
511
+ const last = lastFrontmatterLine(lines, scope);
512
+ return last === -1 ? (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(lines) : (0, markdown_sectionizer_cjs_1.scanFencedBlocks)(lines.map((line, i) => (i <= last ? '' : line)));
513
+ }
514
+ /**
515
+ * Per line of `lines`: whether a field reader or writer must not see it — a
516
+ * fenced block's line (delimiters included) or, in a whole document, a line
517
+ * of frontmatter between its `---` delimiters (YAML data that a Markdown
518
+ * scanner would otherwise read as a heading, a fence or a field).
519
+ */
520
+ function maskedLineFlags(lines, scope) {
521
+ const masked = lines.map(() => false);
522
+ for (const block of markdownFencedBlocks(lines, scope)) {
523
+ const end = block.closeLineIdx === -1 ? lines.length - 1 : block.closeLineIdx;
524
+ for (let i = block.openLineIdx; i <= end; i++)
525
+ masked[i] = true;
526
+ }
527
+ const last = lastFrontmatterLine(lines, scope);
528
+ for (let i = 1; i < last; i++)
529
+ masked[i] = true;
530
+ return masked;
531
+ }
532
+ /**
533
+ * #4998: `content` with every fenced line blanked to spaces — same length,
534
+ * same line terminators — for reading a field the way `writeOutsideFences`
535
+ * writes it: an example line in a fence is not a field. With
536
+ * `{ document: true }` the frontmatter's content lines are blanked too (its
537
+ * `---` delimiters kept), so scanners that do not know frontmatter — the
538
+ * heading tokenizer behind `collectSection`, `parsePlanningDoc` — see only
539
+ * the Markdown body (Codex review rounds 2 and 3).
540
+ */
541
+ function maskFencedLines(content, scope = {}) {
542
+ const lines = content.split('\n');
543
+ const masked = maskedLineFlags(lines, scope);
544
+ return lines.map((line, i) => (masked[i] ? line.replace(/[^\r]/g, ' ') : line)).join('\n');
545
+ }
546
+ /**
547
+ * #4998: apply a single-line field write (`stateReplaceField` and kin) to
548
+ * `content` as if its fenced blocks were absent, so a fenced example line is
549
+ * never the occurrence a write replaces — the same rule
550
+ * `insertMissingSessionFields` follows, through the same scanner. `write` runs
551
+ * against a masked copy (`maskFencedLines`); its one edit is then spliced into
552
+ * the real text at the same offsets. Returns what `write` returns for "no
553
+ * match" (`null`, or the input unchanged as `content`), and `null` if the
554
+ * edit cannot be carried back exactly (it touched a fenced line or changed
555
+ * the line structure) — the stateReplaceField no-match contract, never a
556
+ * partial write.
557
+ */
558
+ function writeOutsideFences(content, write, scope = {}) {
559
+ // Outside a whole document a leading `---` is a horizontal rule
560
+ // (`FenceScope`), but the field writers parse what they are given as a
561
+ // document, where it opens frontmatter and hides the fields below it: a
562
+ // section starting with a rule got a duplicate field inserted (Codex
563
+ // review). They are handed the text after a line break, which is removed again.
564
+ const run = (text) => {
565
+ if (scope.document)
566
+ return write(text);
567
+ const out = write(`\n${text}`);
568
+ return out !== null && out.startsWith('\n') ? out.slice(1) : null;
569
+ };
570
+ const masked = maskFencedLines(content, scope);
571
+ if (masked === content)
572
+ return run(content);
573
+ const result = run(masked);
574
+ if (result === null)
575
+ return null;
576
+ if (result === masked)
577
+ return content;
578
+ const limit = Math.min(masked.length, result.length);
579
+ let p = 0;
580
+ while (p < limit && masked.charCodeAt(p) === result.charCodeAt(p))
581
+ p++;
582
+ let s = 0;
583
+ while (s < limit - p && masked.charCodeAt(masked.length - 1 - s) === result.charCodeAt(result.length - 1 - s))
584
+ s++;
585
+ // The span the write replaced must lie on unfenced lines — an edit that
586
+ // touched a fenced line cannot be carried back, even when the masked and
587
+ // real bytes there happen to agree (a whitespace-only fenced line).
588
+ const lineOf = (offset) => content.slice(0, offset).split('\n').length - 1;
589
+ const fenced = maskedLineFlags(content.split('\n'), scope);
590
+ for (let line = lineOf(p); line <= lineOf(content.length - s); line++)
591
+ if (fenced[line])
592
+ return null;
593
+ const spliced = content.slice(0, p) + result.slice(p, result.length - s) + content.slice(content.length - s);
594
+ return maskFencedLines(spliced, scope) === result ? spliced : null;
595
+ }
596
+ /**
597
+ * #4998: `content` with every `**Label**:` line respelled `**Label:**`, for
598
+ * READING a field the way the writer finds it: `stateReplaceField` matches
599
+ * both spellings, `stateExtractField` only the second, so a `**Resume
600
+ * file**: plan.md` read as absent while the writer could still replace it.
601
+ * Never written back.
602
+ */
603
+ function readableBoldLabels(content) {
604
+ // allow-adhoc-markdown: a read-only copy for stateExtractField, never written back — not a document mutation
605
+ return content.replace(/^([ \t]*)\*\*([^*\r\n]+)\*\*:/gm, '$1**$2:**');
606
+ }
607
+ /** The continuation lines following the line that ends at `afterValue`. */
608
+ function continuationAfter(content, afterValue, ambiguousLabelEnds = true) {
609
+ // JS `.` excludes \r as well as \n, so on a CRLF document the \r sits just
610
+ // AFTER a `(.+)` match rather than inside it — hence the strip below before
611
+ // testing for the newline.
380
612
  const rest = content.slice(afterValue).replace(/^\r/, '');
381
613
  // #5460: U+2028 / U+2029 are line starts to the `m`-flag readers (and stop `(.+)`), so they end
382
614
  // the value's line exactly as `\n` does.
383
615
  if (!/^[\n\u2028\u2029]/.test(rest))
384
616
  return null; // end of file: nothing follows
385
617
  const lines = rest.slice(1).split(LINE_SEPARATOR_RE).map((line) => line.replace(/\r$/, ''));
386
- const continuation = lines.slice(0, continuationLineCount(lines)).map((line) => line.trim());
618
+ const continuation = lines.slice(0, continuationLineCount(lines, ambiguousLabelEnds)).map((line) => line.trim());
387
619
  return continuation.length ? continuation.join(' ') : null;
388
620
  }
389
621
  /**
@@ -391,7 +623,7 @@ function stateFieldContinuation(content, fieldName) {
391
623
  * `stateFieldContinuation` reports on, shared so a writer that replaces the value removes exactly
392
624
  * the lines a validator would call its continuation (#4945). `lines` are CR-free.
393
625
  */
394
- function continuationLineCount(lines) {
626
+ function continuationLineCount(lines, ambiguousLabelEnds = true) {
395
627
  let count = 0;
396
628
  for (let i = 0; i < lines.length; i++) {
397
629
  const line = lines[i];
@@ -399,7 +631,9 @@ function continuationLineCount(lines) {
399
631
  break;
400
632
  if (MD_STRUCTURE_LINE_RE.test(line))
401
633
  break;
402
- if (STATE_SIBLING_FIELD_LINE_RE.test(line))
634
+ // A label line the shape cannot place is a sibling only where the caller
635
+ // fails safe that way (`isUnambiguousSiblingLine`).
636
+ if (STATE_SIBLING_FIELD_LINE_RE.test(line) && (ambiguousLabelEnds || isUnambiguousSiblingLine(line)))
403
637
  break;
404
638
  // Look ahead one line: a setext underline below makes THIS line a heading
405
639
  // title, so stop before consuming it rather than after.
@@ -437,6 +671,63 @@ function fieldContinuationEnd(content, valueEnd) {
437
671
  removed -= 1;
438
672
  return valueEnd + removed;
439
673
  }
674
+ /**
675
+ * #4998: insert each needed field of `fields` — given in template order — into
676
+ * `body`, beside the sibling it follows in that order (below the sibling's own
677
+ * wrapped lines, never between them), else before the sibling it precedes,
678
+ * else at the top in bold. Plain beside a plain sibling, else bold, in the
679
+ * spellings `stateExtractField` reads back. Every existing line is kept, in
680
+ * order, and lines inside a fenced block are examples: never an anchor, never
681
+ * an insertion point. An inserted line ends as the body's lines do (CRLF when
682
+ * the body uses CRLF).
683
+ */
684
+ function insertMissingSessionFields(body, fields) {
685
+ const lines = body.split('\n');
686
+ const fenced = maskedLineFlags(lines, {});
687
+ const cr = body.includes('\r\n') ? '\r' : '';
688
+ // The spellings stateReplaceField writes: `**Label:**`, `**Label**:`, `Label:`.
689
+ const lineOf = (label) => {
690
+ const re = new RegExp(`^[ \\t]*(?:\\*\\*)?${(0, pattern_cjs_1.escapeRegex)(label)}(?::\\*\\*|\\*\\*:|:)(?:\\s|$)`, 'i');
691
+ return lines.findIndex((l, i) => !fenced[i] && re.test(l));
692
+ };
693
+ // Where field k goes: below the nearest earlier sibling present (past its
694
+ // wrapped lines, stopping before a fence the continuation scan does not
695
+ // recognize), else above the nearest later one, else the top.
696
+ const placement = (k) => {
697
+ for (let j = k - 1; j >= 0; j--) {
698
+ const anchor = lineOf(fields[j].label);
699
+ if (anchor === -1)
700
+ continue;
701
+ // An ambiguous `words: text` line is taken as the anchor's tail, as the
702
+ // wrap check takes it: inserting inside a wrapped value splits it
703
+ // (Codex review); inserting below a sibling only moves the new line.
704
+ const tailEnd = anchor + 1 + continuationLineCount(lines.slice(anchor + 1).map((l) => l.replace(/\r$/, '')), false);
705
+ let at = anchor + 1;
706
+ while (at < tailEnd && !fenced[at])
707
+ at++;
708
+ return { at, anchor };
709
+ }
710
+ for (let j = k + 1; j < fields.length; j++) {
711
+ const anchor = lineOf(fields[j].label);
712
+ if (anchor !== -1)
713
+ return { at: anchor, anchor };
714
+ }
715
+ return { at: 0, anchor: -1 };
716
+ };
717
+ fields.forEach(({ label, needed, value }, k) => {
718
+ if (!needed)
719
+ return;
720
+ const { at, anchor } = placement(k);
721
+ const bold = anchor === -1 || lines[anchor].trimStart().startsWith('**');
722
+ const line = (bold ? `**${label}:** ` : `${label}: `) + value;
723
+ // Appended after an unterminated last line: that line gains the CR.
724
+ if (at === lines.length)
725
+ lines[at - 1] += cr;
726
+ lines.splice(at, 0, at === lines.length ? line : line + cr);
727
+ fenced.splice(at, 0, false);
728
+ });
729
+ return lines.join('\n');
730
+ }
440
731
  function stateExtractField(content, fieldName) {
441
732
  const escaped = (0, pattern_cjs_1.escapeRegex)(fieldName);
442
733
  // Bold line-start format: **FieldName:** value. Leading same-line whitespace
@@ -644,14 +935,13 @@ function stateReplaceField(content, fieldName, newValue) {
644
935
  // is the same, already-precedented widening as the "Depends on"
645
936
  // migration (src/phase.cts) — intentional, not scope creep.
646
937
  //
647
- // A parse failure, an absent/case-mismatched label, or a value containing
648
- // a line break (\r/\n — would forge sibling structure on splice, the same
649
- // hazard `setFieldValue` refuses unconditionally at #4917/ADR-4910
650
- // Decision 2 & 4; this local splice has no seam call to inherit that
651
- // refusal from, so it is re-checked here) falls through to the
938
+ // A parse failure or an absent/case-mismatched label falls through to the
652
939
  // plain/pipe-table branches below unchanged — the same silent
653
940
  // per-occurrence no-op-on-no-match contract the removed regex had for any
654
- // input it didn't match.
941
+ // input it didn't match. A value containing a line break
942
+ // (`FIELD_LINE_BREAK_RE` — would forge sibling structure on splice, the
943
+ // same hazard `setFieldValue` refuses at #4917/ADR-4910 Decision 2 & 4)
944
+ // is refused by every rung (#4998): the function returns null.
655
945
  const parsed = (0, planning_document_cjs_1.parsePlanningDoc)(content, 'STATE.md');
656
946
  if (parsed.ok) {
657
947
  let boldField = null;
@@ -675,11 +965,16 @@ function stateReplaceField(content, fieldName, newValue) {
675
965
  const newValueStr = `${newValue}`;
676
966
  const needsSeparator = newValueStr.length > 0 && !/[ \t]$/.test(spacingText);
677
967
  const writeValue = `${needsSeparator ? ' ' : ''}${newValueStr}`;
678
- if (!/[\r\n]/.test(writeValue)) {
968
+ if (!planning_document_cjs_1.FIELD_LINE_BREAK_RE.test(writeValue)) {
679
969
  return (content.slice(0, boldField.valueSpan.start) + writeValue + content.slice(boldField.trailingSpan.end));
680
970
  }
681
971
  }
682
972
  }
973
+ // #4998: the plain and table rungs refuse a line break too. Spliced in, it
974
+ // forges sibling lines exactly as the bold rung's refusal above prevents;
975
+ // falling through to them only moved the forgery one rung down.
976
+ if (planning_document_cjs_1.FIELD_LINE_BREAK_RE.test(`${newValue}`))
977
+ return null;
683
978
  // Plain line-start format: FieldName: value (same same-line confinement as above)
684
979
  const plainPattern = new RegExp(`(^${escaped}:[ \\t]*)(.*)`, 'im');
685
980
  if (plainPattern.test(content)) {
@@ -722,14 +1017,28 @@ function stateReplaceFieldWithFallback(content, primary, fallback, value) {
722
1017
  * Replace-only (no insertion): returns `content` unchanged when no session
723
1018
  * section exists or the field is absent from it, so a STATE.md layout without
724
1019
  * the line keeps its shape and the post-sync preservation pass decides the
725
- * frontmatter value (see #3374).
1020
+ * frontmatter value (see #3374). Lines inside a fenced block are examples and
1021
+ * are never the occurrence replaced (#4998, `writeOutsideFences`).
726
1022
  */
727
1023
  function stateReplaceFieldInSession(content, primary, fallback, value) {
728
- const isSession = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session';
729
- const isSessionContinuity = (h) => h.level === 2 && h.text.trim().toLowerCase() === 'session continuity';
730
- const hasCanonicalSession = (0, markdown_sectionizer_cjs_1.collectSection)(content, isSession, { levelBounded: true }) !== null;
731
- const target = hasCanonicalSession ? isSession : isSessionContinuity;
732
- return (0, markdown_sectionizer_cjs_1.withSection)(content, target, (sectionBody) => stateReplaceFieldWithFallback(sectionBody, primary, fallback, value));
1024
+ const hasCanonicalSession = (0, markdown_sectionizer_cjs_1.collectSection)(content, isSessionHeading, { levelBounded: true }) !== null;
1025
+ const target = hasCanonicalSession ? isSessionHeading : isSessionContinuityHeading;
1026
+ // #4998: a fenced example line is never the field written, as the session
1027
+ // readers (state.cts sessionFieldScope) never read one.
1028
+ return (0, markdown_sectionizer_cjs_1.withSection)(content, target, (sectionBody) => writeOutsideFences(sectionBody, (visible) => stateReplaceFieldWithFallback(visible, primary, fallback, value)) ?? sectionBody);
1029
+ }
1030
+ /**
1031
+ * The two session headings, as every session reader and writer recognises
1032
+ * them: one grammar (the `collectSection` tokenizer), so a heading the readers
1033
+ * take is never one a writer misses. #4998 review: `state record-session`
1034
+ * detected the section with its own regex, which rejected `## Session`, and
1035
+ * appended a duplicate section.
1036
+ */
1037
+ function isSessionHeading(h) {
1038
+ return h.level === 2 && h.text.trim().toLowerCase() === 'session';
1039
+ }
1040
+ function isSessionContinuityHeading(h) {
1041
+ return h.level === 2 && h.text.trim().toLowerCase() === 'session continuity';
733
1042
  }
734
1043
  /**
735
1044
  * #4186: the DECLARED raw-status vocabulary `normalizeStateStatus` recognizes.
@@ -19,6 +19,7 @@ exports.STATE_MD_SECTIONS = exports.FRONTMATTER_BODY_SOURCE = exports.FIELD_CLAS
19
19
  exports.formatProgressMachineSegment = formatProgressMachineSegment;
20
20
  exports.stateReplaceProgressPercent = stateReplaceProgressPercent;
21
21
  exports.beginFrontmatterReassembly = beginFrontmatterReassembly;
22
+ exports.isSessionScopedBodyField = isSessionScopedBodyField;
22
23
  exports.getFrontmatterBodySource = getFrontmatterBodySource;
23
24
  exports.frontmatterKeyForBodyField = frontmatterKeyForBodyField;
24
25
  exports.getFieldClassification = getFieldClassification;
@@ -37,10 +38,11 @@ const state_document_cjs_1 = require("./state-document.cjs");
37
38
  const state_document_cjs_2 = require("./state-document.cjs");
38
39
  const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
39
40
  const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
41
+ const roadmap_progress_cjs_1 = require("./roadmap-progress.cjs");
40
42
  const pattern_cjs_1 = require("./pattern.cjs");
41
43
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
42
44
  const phaseIdMod = require("./phase-id.cjs");
43
- const { comparePhaseNum } = phaseIdMod;
45
+ const { comparePhaseNum, parsePhaseFromProse } = phaseIdMod;
44
46
  // #4129: the completion-ratio kernel for the resync-arm ratchet's percent
45
47
  // (planning-scope's SCOPE — state-document's own dependency, no cycle here:
46
48
  // state-document never imports this module).
@@ -250,6 +252,13 @@ exports.FRONTMATTER_BODY_SOURCE = Object.freeze(FRONTMATTER_BODY_SOURCE_KEY_ORDE
250
252
  * docstring). `updateCore` was still doing the whole-body replace.
251
253
  */
252
254
  const SESSION_SCOPED_KEYS = new Set(['stopped_at', 'paused_at']);
255
+ /**
256
+ * #4998 review: the `## Session` fields with no frontmatter key. `state-snapshot`
257
+ * reads them from the session section only, with fenced blocks blanked, so
258
+ * `state update` / `state patch` write them there too — never a fenced example
259
+ * above the live line, and never an archive section's copy.
260
+ */
261
+ const SESSION_BODY_FIELDS = new Set(['resume file', 'last session', 'last date']);
253
262
  /**
254
263
  * The `(primary, fallback)` label pair for a session-scoped frontmatter KEY.
255
264
  */
@@ -261,7 +270,8 @@ function sessionLabelsForKey(key) {
261
270
  }
262
271
  /**
263
272
  * The same pair, resolved from a BODY LABEL the caller named (`Stopped At`,
264
- * `Stopped at`, `Paused At`). `null` for anything else.
273
+ * `Stopped at`, `Paused At`, or a SESSION_BODY_FIELDS label as named). `null`
274
+ * for anything else.
265
275
  *
266
276
  * Deliberately does NOT accept a frontmatter key. An earlier cut resolved both
267
277
  * spellings through one function and used it for the write, which made
@@ -272,7 +282,13 @@ function sessionLabelsForKey(key) {
272
282
  */
273
283
  function sessionLabelsForBodyField(field) {
274
284
  const key = frontmatterKeyForBodyField(field);
275
- return key === null ? null : sessionLabelsForKey(key);
285
+ if (key !== null)
286
+ return sessionLabelsForKey(key);
287
+ return SESSION_BODY_FIELDS.has(field.trim().toLowerCase()) ? { primary: field, fallback: null } : null;
288
+ }
289
+ /** #4998 review: whether `state update` / `state patch` write the body field `field` inside the session section. */
290
+ function isSessionScopedBodyField(field) {
291
+ return sessionLabelsForBodyField(field) !== null;
276
292
  }
277
293
  /**
278
294
  * Would a session-scoped write actually land? Asks by attempting the real write
@@ -1025,7 +1041,7 @@ function beginPhaseCore(content, intent, deps) {
1025
1041
  tryField('Current Phase Name', intent.phaseName);
1026
1042
  }
1027
1043
  tryField('Current Plan', String(startPlan));
1028
- if (intent.planCount) {
1044
+ if (intent.planCount != null) {
1029
1045
  tryField('Total Plans in Phase', String(intent.planCount));
1030
1046
  }
1031
1047
  // **Current focus:** body text line (#1104).
@@ -1163,7 +1179,7 @@ function mutateCurrentPositionFirstTime(body, intent, today, updated) {
1163
1179
  sectionBody = replaced;
1164
1180
  }
1165
1181
  // Plan line.
1166
- const planValue = `${intent.startPlan} of ${intent.planCount || '?'}`;
1182
+ const planValue = `${intent.startPlan} of ${intent.planCount ?? '?'}`;
1167
1183
  if (/^Plan:/m.test(sectionBody)) {
1168
1184
  sectionBody = sectionBody.replace(/^Plan:.*$/m, `Plan: ${planValue}`);
1169
1185
  }
@@ -1708,8 +1724,10 @@ function nextPhaseWording(disk) {
1708
1724
  * position; the caller combines this with "the phase is already complete in
1709
1725
  * ROADMAP.md" so a first-time out-of-order completion (#2028) still
1710
1726
  * transitions. An unparseable or absent current phase is "not past".
1727
+ * #5676: under `milestone-prefixed` the id is the whole `M-NN` (`3-02`, not `3`); that read goes
1728
+ * through `parsePhaseFromProse` (the #5626 grammar owner), so the two cannot drift.
1711
1729
  */
1712
- function isStatePastPhase(content, phaseNum, sourcePath) {
1730
+ function isStatePastPhase(content, phaseNum, sourcePath, convention) {
1713
1731
  const fm = extractFrontmatter(content, sourcePath);
1714
1732
  const fromFm = fm['current_phase'];
1715
1733
  let raw = typeof fromFm === 'string' || typeof fromFm === 'number' ? String(fromFm) : null;
@@ -1718,10 +1736,15 @@ function isStatePastPhase(content, phaseNum, sourcePath) {
1718
1736
  raw = (0, state_document_cjs_1.stateExtractField)(body, 'Current Phase') || (0, state_document_cjs_1.stateExtractField)(body, 'Phase') || null;
1719
1737
  }
1720
1738
  // `Phase 4` / `phase 4 of 9` name phase 4 as surely as `4 of 9` does.
1721
- const m = raw === null ? null : /^\s*(?:phase\s+)?(\d+(?:\.\d+)*)/i.exec(raw);
1722
- if (!m)
1739
+ let id = null;
1740
+ if (raw !== null) {
1741
+ id = convention === 'milestone-prefixed'
1742
+ ? parsePhaseFromProse(raw, 'milestone-prefixed').phase
1743
+ : (/^\s*(?:phase\s+)?(\d+(?:\.\d+)*)/i.exec(raw)?.[1] ?? null);
1744
+ }
1745
+ if (id === null)
1723
1746
  return false;
1724
- return comparePhaseNum(m[1], String(phaseNum)) > 0;
1747
+ return comparePhaseNum(id, String(phaseNum)) > 0;
1725
1748
  }
1726
1749
  /**
1727
1750
  * Apply a `completePhase` transition to STATE.md content.
@@ -1754,8 +1777,8 @@ function completePhaseCore(content, intent, deps) {
1754
1777
  // else the milestone STATE asserts. It scopes the roadmap-derived counters
1755
1778
  // below to that milestone's Progress-table rows, the same milestone
1756
1779
  // `phase complete` scopes its frontmatter progress to.
1757
- const currentMilestone = (0, phase_lifecycle_cjs_1.milestoneVersionOf)(intent.milestone)
1758
- ?? (0, phase_lifecycle_cjs_1.milestoneVersionOf)(extractFrontmatter(content, deps.sourcePath)['milestone']);
1780
+ const currentMilestone = (0, roadmap_progress_cjs_1.milestoneVersionOf)(intent.milestone)
1781
+ ?? (0, roadmap_progress_cjs_1.milestoneVersionOf)(extractFrontmatter(content, deps.sourcePath)['milestone']);
1759
1782
  // Consult the field-classification table for the frontmatter keys this
1760
1783
  // transition touches (same guard beginPhaseCore applies). A missing row is a
1761
1784
  // substrate defect — fail loudly rather than silently re-encoding policy.
@@ -2350,7 +2373,20 @@ function patchCore(content, intent) {
2350
2373
  // `^field:` pattern — e.g. `status` matching both the frontmatter key
2351
2374
  // and a `Status:` body line). Frontmatter is only consulted when the
2352
2375
  // body has no match for this key.
2353
- const replaced = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
2376
+ // #4998: a session field is written the way `state update` writes it —
2377
+ // inside the session section, never a fenced example there — so the
2378
+ // patch agrees with the session readers the frontmatter sync uses.
2379
+ const sessionWriteLabels = sessionLabelsForBodyField(field);
2380
+ let replaced;
2381
+ if (sessionWriteLabels) {
2382
+ const inSession = (0, state_document_cjs_1.stateReplaceFieldInSession)(body, sessionWriteLabels.primary, sessionWriteLabels.fallback, value);
2383
+ // Unchanged is a miss only when the field is absent: one already holding
2384
+ // the value matched (#4488), as stateReplaceField reports it.
2385
+ replaced = inSession !== body || sessionSourceExists(body, sessionWriteLabels) ? inSession : null;
2386
+ }
2387
+ else {
2388
+ replaced = (0, state_document_cjs_1.stateReplaceField)(body, field, value);
2389
+ }
2354
2390
  if (replaced !== null) {
2355
2391
  body = replaced;
2356
2392
  updated.push(field);
@@ -2416,10 +2452,10 @@ function updateCore(content, intent) {
2416
2452
  const sessionWriteLabels = sessionLabelsForBodyField(intent.field);
2417
2453
  let result;
2418
2454
  if (sessionWriteLabels) {
2419
- // Replace-only by contract: unchanged content means the field is not in the
2420
- // session section, which is a miss, not a write.
2455
+ // Replace-only by contract: the session writer never inserts a missing field.
2421
2456
  const replaced = (0, state_document_cjs_1.stateReplaceFieldInSession)(body, sessionWriteLabels.primary, sessionWriteLabels.fallback, intent.value);
2422
- result = replaced === body ? null : replaced;
2457
+ // A field already holding the value matched; unchanged with no field is the miss (#4488).
2458
+ result = replaced !== body || sessionSourceExists(body, sessionWriteLabels) ? replaced : null;
2423
2459
  }
2424
2460
  else {
2425
2461
  result = (0, state_document_cjs_1.stateReplaceField)(body, intent.field, intent.value);