@opengsd/gsd-core 1.16.0 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -2
  3. package/.opencode/plugins/gsd-core.js +251 -1
  4. package/.opencode/plugins/gsd-statusline/tui.tsx +55 -0
  5. package/GEMINI.md +1 -1
  6. package/agents/gsd-debug-session-manager.compact.md +6 -0
  7. package/agents/gsd-debug-session-manager.md +6 -0
  8. package/bin/gsd-mcp-server.js +3 -1
  9. package/bin/install.js +361 -94
  10. package/gsd-core/bin/gsd-tools.cjs +17 -2
  11. package/gsd-core/bin/lib/adr-parser.cjs +37 -5
  12. package/gsd-core/bin/lib/capability-registry.cjs +132 -80
  13. package/gsd-core/bin/lib/capability-validator.cjs +2 -1
  14. package/gsd-core/bin/lib/capability-writer.cjs +2 -1
  15. package/gsd-core/bin/lib/commands.cjs +28 -8
  16. package/gsd-core/bin/lib/gate-context-drift.cjs +5 -4
  17. package/gsd-core/bin/lib/gate-ui-safety.cjs +2 -1
  18. package/gsd-core/bin/lib/graphify.cjs +3 -2
  19. package/gsd-core/bin/lib/init.cjs +32 -12
  20. package/gsd-core/bin/lib/install-engine.cjs +11 -0
  21. package/gsd-core/bin/lib/install-shadow-report.cjs +12 -0
  22. package/gsd-core/bin/lib/installer-migrations/011-opencode-mcp-no-prompts.cjs +99 -0
  23. package/gsd-core/bin/lib/installer-migrations.cjs +54 -1
  24. package/gsd-core/bin/lib/jsonc-edit.cjs +418 -0
  25. package/gsd-core/bin/lib/loop-host-contract.cjs +2 -1
  26. package/gsd-core/bin/lib/mcp-server.cjs +5 -1
  27. package/gsd-core/bin/lib/opencode-mcp-entry.cjs +39 -0
  28. package/gsd-core/bin/lib/opencode-version.cjs +486 -0
  29. package/gsd-core/bin/lib/phase.cjs +21 -0
  30. package/gsd-core/bin/lib/quick-batch-dispatch.cjs +2 -1
  31. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +95 -4
  32. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +58 -3
  33. package/gsd-core/bin/lib/runtime-slash.cjs +13 -0
  34. package/gsd-core/bin/lib/smart-entry.cjs +92 -53
  35. package/gsd-core/bin/lib/state-transition.cjs +67 -9
  36. package/gsd-core/bin/lib/state.cjs +454 -66
  37. package/gsd-core/bin/lib/update-context.cjs +18 -1
  38. package/gsd-core/bin/lib/verification.cjs +107 -17
  39. package/gsd-core/workflows/code-review.md +16 -1
  40. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
  41. package/gsd-core/workflows/execute-phase.md +3 -1
  42. package/gsd-core/workflows/resume-project.md +14 -10
  43. package/hooks/dist/gsd-check-update.js +26 -20
  44. package/hooks/dist/gsd-context-monitor.js +14 -5
  45. package/hooks/dist/gsd-secret-read-guard.js +117 -1
  46. package/hooks/dist/gsd-statusline.js +321 -22
  47. package/hooks/gsd-check-update.js +26 -20
  48. package/hooks/gsd-context-monitor.js +14 -5
  49. package/hooks/gsd-secret-read-guard.js +117 -1
  50. package/hooks/gsd-statusline.js +321 -22
  51. package/package.json +1 -1
  52. package/scripts/gen-capability-registry.cjs +7 -1
  53. package/scripts/gen-loop-host-contract.cjs +4 -1
  54. package/scripts/lib/macos-conformance-tier.generated.cjs +4 -0
  55. package/scripts/lib/platform-conformance-tier.generated.cjs +10 -0
  56. package/scripts/mutation-matrix.cjs +3 -1
  57. package/scripts/release-tarball-smoke.cjs +14 -7
  58. package/vscode/package.json +1 -1
@@ -4513,7 +4513,98 @@ function computeChangedFrontmatterFields(snapshotFm, persistedFm, bodyDeltas) {
4513
4513
  * (#3818's own direction), which is why keeping it as the candidate
4514
4514
  * source was rejected (design doc, Rejected #1).
4515
4515
  */
4516
- function reconcileReportedFields(statePath, preWriteState, reported, divergedFields) {
4516
+ /**
4517
+ * ADR-4629 §8.2 (epic #4629, C2): the value a reported/asserted target
4518
+ * resolves to in one document side. Lifted verbatim out of
4519
+ * `reconcileReportedFields` (it was the private `valueOf` closure) so the C2
4520
+ * verifying executor reads a target through the SAME resolution chain the
4521
+ * measured-delta reporter uses — ADR-4629 Decision 4: extend the reporter,
4522
+ * never build a second one.
4523
+ *
4524
+ * #3471 review (unchanged): body-FIRST, frontmatter-key-FLAT-fallback,
4525
+ * dotted-PATH-fallback last. Body-first mirrors the actual write precedence
4526
+ * `patchCore`/`updateCore` apply (#1162's fix — a lowercase body label that
4527
+ * happens to case-exact-match a frontmatter key must still resolve against
4528
+ * the body). `field` a literal flat key (even one containing a `.`) is tried
4529
+ * before it is split and walked as a dotted path (test matrix row 26) —
4530
+ * `resolveFrontmatterPath` pins that same order for the frontmatter side.
4531
+ *
4532
+ * `Current Position` is special-cased: it names the WHOLE `## Current
4533
+ * Position` section, not a single `Label: value` line, so `stateExtractField`
4534
+ * can never resolve it (the root cause of the "Current Position undercount" —
4535
+ * a transform could correctly push `'Current Position'` into its own
4536
+ * `updated` list and it was still silently dropped, because both sides
4537
+ * resolved to `null` and `null === null` failed the old `intended !== null`
4538
+ * guard). `sliceCurrentPositionSection` is the existing fence-aware section
4539
+ * locator (state-transition.cts) — reused rather than re-derived.
4540
+ *
4541
+ * C2 generalizes that special case: a name in `sectionTargets` (a declared
4542
+ * `target: 'section'` assertion) resolves to its whole section body through
4543
+ * the markdown-sectionizer seam (ADR-1372), never a hand-rolled regex.
4544
+ * Existing callers pass no `sectionTargets` and resolve exactly as before.
4545
+ */
4546
+ function readStateTarget(fm, body, field, sectionTargets) {
4547
+ if (field === 'Current Position') {
4548
+ const section = stateTransitionMod.sliceCurrentPositionSection(body);
4549
+ return section !== null ? section.trim() : null;
4550
+ }
4551
+ if (sectionTargets.has(field)) {
4552
+ const section = (0, markdown_sectionizer_cjs_1.collectSection)(body, (h) => h.text === field);
4553
+ return section !== null ? section.body.trim() : null;
4554
+ }
4555
+ const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
4556
+ if (bodyValue !== null)
4557
+ return bodyValue;
4558
+ if (Object.prototype.hasOwnProperty.call(fm, field))
4559
+ return String(fm[field]);
4560
+ if (field.includes('.')) {
4561
+ const resolved = resolveFrontmatterPath(fm, field);
4562
+ if (resolved !== STATE_FIELD_ABSENT) {
4563
+ return stateScalarString(resolved);
4564
+ }
4565
+ }
4566
+ return null;
4567
+ }
4568
+ const NO_SECTION_TARGETS = new Set();
4569
+ /**
4570
+ * ADR-4629 §8.2 (C2): did this target measurably change between the two
4571
+ * document sides? Lifted verbatim out of `reconcileReportedFields` (the private
4572
+ * `changed` closure) for the same one-rule reason as `readStateTarget`.
4573
+ *
4574
+ * A target can itself be a declared derived leaf (e.g. `plannedPhaseCore`
4575
+ * pushing `'progress.total_plans'` — state-transition.cts). `readStateTarget`'s
4576
+ * null-vs-string convention cannot tell "absent from the frontmatter" apart
4577
+ * from "resolved to the literal string 'null'/''", so it cannot carry the same
4578
+ * materialization rule `computeChangedFrontmatterFields` applies. Those targets
4579
+ * route through the SAME primitives (`resolveFrontmatterPath` + the
4580
+ * `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
4581
+ * second, parallel absence convention — one rule, reused, not duplicated.
4582
+ */
4583
+ function stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets) {
4584
+ const isDeclaredDerivedLeaf = field.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, field);
4585
+ if (isDeclaredDerivedLeaf && !sectionTargets.has(field)) {
4586
+ const before = resolveFrontmatterPath(snapshotFm, field);
4587
+ const after = resolveFrontmatterPath(persistedFm, field);
4588
+ // Same generalized provenance rule as computeChangedFrontmatterFields:
4589
+ // absent-in-snapshot-materializing-in-persisted is the disk scan
4590
+ // catching a never-synced document up, not this write's own action.
4591
+ if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
4592
+ return false;
4593
+ return stateFieldValuesDiffer(before, after);
4594
+ }
4595
+ const before = readStateTarget(snapshotFm, snapshotBody, field, sectionTargets);
4596
+ const after = readStateTarget(persistedFm, persistedBody, field, sectionTargets);
4597
+ if (before === null && after === null)
4598
+ return false;
4599
+ if (before === null || after === null)
4600
+ return true;
4601
+ return before.trim() !== after.trim();
4602
+ }
4603
+ function reconcileReportedFields(statePath, preWriteState, reported, divergedFields,
4604
+ // ADR-4629 §8.2 / Decision 4 (C2): names in `reported` that are declared
4605
+ // `target: 'section'` assertions, measured as whole-section deltas. Omitted
4606
+ // by every pre-C2 caller, whose output is unchanged.
4607
+ sectionTargets = NO_SECTION_TARGETS) {
4517
4608
  void divergedFields; // ADR-3473 §8.7 D18: out-param only, not a candidate source here.
4518
4609
  if (preWriteState.fm === undefined || preWriteState.body === undefined)
4519
4610
  return [];
@@ -4522,71 +4613,7 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
4522
4613
  const persistedBody = stripFrontmatter(persisted);
4523
4614
  const snapshotFm = preWriteState.fm;
4524
4615
  const snapshotBody = preWriteState.body;
4525
- // #3471 review (unchanged by this rewrite): body-FIRST, frontmatter-key-
4526
- // FLAT-fallback, dotted-PATH-fallback last. Body-first mirrors the actual
4527
- // write precedence `patchCore`/`updateCore` apply (#1162's fix — a
4528
- // lowercase body label that happens to case-exact-match a frontmatter key
4529
- // must still resolve against the body). `field` a literal flat key (even
4530
- // one containing a `.`) is tried before it is split and walked as a
4531
- // dotted path (test matrix row 26) — `resolveFrontmatterPath` pins that
4532
- // same order for the frontmatter side alone.
4533
- //
4534
- // `Current Position` is special-cased: it names the WHOLE `## Current
4535
- // Position` section, not a single `Label: value` line, so
4536
- // `stateExtractField` can never resolve it (this is the root cause of the
4537
- // "Current Position undercount" — a transform can correctly push
4538
- // `'Current Position'` into its own `updated` list, and this function
4539
- // still silently dropped it, because `valueOf` returned `null` for BOTH
4540
- // sides and `null === null` failed the old `intended !== null` guard).
4541
- // `sliceCurrentPositionSection` is the existing fence-aware section
4542
- // locator (state-transition.cts) — reused rather than re-derived.
4543
- const valueOf = (fm, body, field) => {
4544
- if (field === 'Current Position') {
4545
- const section = stateTransitionMod.sliceCurrentPositionSection(body);
4546
- return section !== null ? section.trim() : null;
4547
- }
4548
- const bodyValue = (0, state_document_cjs_1.stateExtractField)(body, field);
4549
- if (bodyValue !== null)
4550
- return bodyValue;
4551
- if (Object.prototype.hasOwnProperty.call(fm, field))
4552
- return String(fm[field]);
4553
- if (field.includes('.')) {
4554
- const resolved = resolveFrontmatterPath(fm, field);
4555
- if (resolved !== STATE_FIELD_ABSENT) {
4556
- return stateScalarString(resolved);
4557
- }
4558
- }
4559
- return null;
4560
- };
4561
- // A field in `reported` can itself be a declared derived leaf (e.g.
4562
- // `plannedPhaseCore` pushing `'progress.total_plans'` — state-
4563
- // transition.cts:1752). `valueOf`'s null-vs-string convention cannot tell
4564
- // "absent from the frontmatter" apart from "resolved to the literal string
4565
- // 'null'/''", so it cannot carry the same materialization rule
4566
- // `computeChangedFrontmatterFields` applies below. Route these fields
4567
- // through the SAME primitives (`resolveFrontmatterPath` + the
4568
- // `STATE_FIELD_ABSENT` sentinel + `stateFieldValuesDiffer`) instead of a
4569
- // second, parallel absence convention — one rule, reused, not duplicated.
4570
- const isDeclaredDerivedLeaf = (candidate) => candidate.includes('.') && Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, candidate);
4571
- const changed = (field) => {
4572
- if (isDeclaredDerivedLeaf(field)) {
4573
- const before = resolveFrontmatterPath(snapshotFm, field);
4574
- const after = resolveFrontmatterPath(persistedFm, field);
4575
- // Same generalized provenance rule as computeChangedFrontmatterFields:
4576
- // absent-in-snapshot-materializing-in-persisted is the disk scan
4577
- // catching a never-synced document up, not this write's own action.
4578
- if (before === STATE_FIELD_ABSENT && after !== STATE_FIELD_ABSENT)
4579
- return false;
4580
- return stateFieldValuesDiffer(before, after);
4581
- }
4582
- const before = valueOf(snapshotFm, snapshotBody, field);
4583
- const after = valueOf(persistedFm, persistedBody, field);
4584
- if (before === null && after === null)
4585
- return false;
4586
- if (before === null || after === null)
4587
- return true;
4588
- return before.trim() !== after.trim();
4589
- };
4616
+ const changed = (field) => stateTargetChanged(snapshotFm, snapshotBody, persistedFm, persistedBody, field, sectionTargets);
4590
4617
  // Candidate set = `reported` ∪ every frontmatter key (dotted-leaf
4591
4618
  // granularity) whose persisted value differs from the snapshot, minus the
4592
4619
  // provenance exclusion. A frontmatter-diff-discovered field is mapped
@@ -4614,6 +4641,361 @@ function reconcileReportedFields(statePath, preWriteState, reported, divergedFie
4614
4641
  }
4615
4642
  return reconciled;
4616
4643
  }
4644
+ /** Why a verified write failed. A closed set, frozen like `ERROR_REASON`. */
4645
+ const STATE_WRITE_INTENT_FAILURE = Object.freeze({
4646
+ /** §8.3: the delta reached a region outside the declared scope. */
4647
+ OUT_OF_SCOPE: 'out_of_scope',
4648
+ /** §8.2: a `required` assertion did not land. */
4649
+ REQUIRED_ASSERTION_MISSED: 'required_assertion_missed',
4650
+ /** §8.2: the re-read file is not the content that was verified and written. */
4651
+ REREAD_MISMATCH: 'reread_mismatch',
4652
+ });
4653
+ /**
4654
+ * Split a STATE.md body into leaf units for the §8.3 diff: the preamble before
4655
+ * the first heading, then one unit per heading running to the NEXT heading of
4656
+ * any level, in document order. Leaf granularity means a declared
4657
+ * `## Accumulated Context` scope reaches its `### Decisions` child by heading
4658
+ * level (`stateSectionScopeUnits`), while an undeclared sibling section stays a separate unit. Built on
4659
+ * the markdown-sectionizer seam (ADR-1372), which is fence-aware, so a `#` line
4660
+ * inside a code fence is not a heading. A repeated heading gets an occurrence
4661
+ * suffix so neither shadows the other.
4662
+ */
4663
+ function collectStateBodyUnits(body) {
4664
+ const units = new Map();
4665
+ const sections = (0, markdown_sectionizer_cjs_1.collectSections)(body, () => true);
4666
+ const preambleEnd = sections.length > 0 ? sections[0].heading.offset : body.length;
4667
+ units.set('(preamble)', { text: body.slice(0, preambleEnd).trim(), level: 0, region: { region: 'preamble', name: '' } });
4668
+ const occurrences = new Map();
4669
+ for (const section of sections) {
4670
+ const { heading } = section;
4671
+ const base = `${'#'.repeat(heading.level)} ${heading.text}`;
4672
+ const n = (occurrences.get(base) ?? 0) + 1;
4673
+ occurrences.set(base, n);
4674
+ units.set(n === 1 ? base : `${base} (#${n})`, {
4675
+ text: section.body.trim(),
4676
+ level: heading.level,
4677
+ region: { region: 'section', name: heading.text },
4678
+ });
4679
+ }
4680
+ return units;
4681
+ }
4682
+ /** The raw frontmatter region (everything before the body), for the unparseable-block case. */
4683
+ function stateFrontmatterRegion(content) {
4684
+ return content.slice(0, content.length - stripFrontmatter(content).length);
4685
+ }
4686
+ /**
4687
+ * The unit a declared field lives in: the first unit, in document order, where
4688
+ * `stateExtractField` resolves it. That mirrors `stateExtractField`'s own
4689
+ * first-match reading of the whole body, so a declared `Status` covers the one
4690
+ * line the seam treats as `Status`, and not a second `Status:` line elsewhere.
4691
+ */
4692
+ function stateFieldOwnerUnit(units, field) {
4693
+ for (const [key, unit] of units) {
4694
+ if ((0, state_document_cjs_1.stateExtractField)(unit.text, field) !== null)
4695
+ return key;
4696
+ }
4697
+ return null;
4698
+ }
4699
+ /**
4700
+ * The units a declared section covers: the FIRST unit, in document order, whose
4701
+ * heading text is the declared name (the occurrence `readStateTarget` reads,
4702
+ * through `collectSection`'s first match), plus every following unit nested
4703
+ * under it by heading level. The section analogue of `stateFieldOwnerUnit`: a
4704
+ * repeated heading of the same name, and that occurrence's children, are not
4705
+ * covered, even though they sit under a heading with the same text.
4706
+ */
4707
+ function stateSectionScopeUnits(units, sectionTargets) {
4708
+ const covered = new Set();
4709
+ const entries = [...units];
4710
+ for (const name of sectionTargets) {
4711
+ const start = entries.findIndex(([, u]) => u.region.region === 'section' && u.region.name === name);
4712
+ if (start < 0)
4713
+ continue;
4714
+ const ownerLevel = entries[start][1].level;
4715
+ covered.add(entries[start][0]);
4716
+ for (let i = start + 1; i < entries.length && entries[i][1].level > ownerLevel; i++)
4717
+ covered.add(entries[i][0]);
4718
+ }
4719
+ return covered;
4720
+ }
4721
+ /**
4722
+ * ADR-4629 §8.2 + §8.3: verify one write against its declared intent. PURE —
4723
+ * it compares two document sides and never touches disk.
4724
+ *
4725
+ * Both sides are first passed through `normalizeContent`, the OS projection
4726
+ * seam's own normalization (CRLF strip, blank-line-run collapse, one trailing
4727
+ * newline). `platformWriteSync` applies it to every STATE.md write, on this
4728
+ * path and the legacy one alike, so it is the seam's behavior, not the
4729
+ * intent's delta. Without it, a CRLF file would read as every section changed.
4730
+ *
4731
+ * §8.2 (verified post-state): every `required` assertion must land. With a
4732
+ * declared `value`, "landed" means the target reads that value afterwards (an
4733
+ * idempotent write that re-asserts the current value passes). Without one,
4734
+ * "landed" means the target is still present AND measurably changed
4735
+ * (`stateTargetChanged`, the measured-delta test `updated[]` uses); a deleted
4736
+ * target has not landed. Best-effort assertions are NOT judged: their report
4737
+ * bucket is ADR-4629 §8.4, an open question for Phase 3, so this result
4738
+ * deliberately carries no best-effort field for a later phase to be bound by.
4739
+ *
4740
+ * §8.3 (bounded mutation): the delta must stay inside the declared scope.
4741
+ * - Body: diffed per leaf unit (`collectStateBodyUnits`). A changed unit is in
4742
+ * scope when it is the first occurrence of a section a declared
4743
+ * `target: 'section'` assertion names, or nested under that occurrence
4744
+ * (`stateSectionScopeUnits`). Otherwise it is in scope only when replaying the
4745
+ * post-values of the declared fields THAT UNIT OWNS (`stateFieldOwnerUnit`)
4746
+ * onto its pre text reproduces its post text, so any other edit in the same
4747
+ * section, or the same label in another section, is caught. A change in
4748
+ * section ORDER is its own out-of-scope region.
4749
+ * - Frontmatter: keys with a body source (`getFrontmatterBodySource`) are
4750
+ * judged through that source, because `syncStateFrontmatter` re-derives them
4751
+ * from the body on every write. When the seam's `bodyDeltas` show the source
4752
+ * moved, the body check above already bounded that move. A mirrored key that
4753
+ * changed with no body move, or with no `bodyDeltas` to show one, must be
4754
+ * declared. Every other key goes through `computeChangedFrontmatterFields`
4755
+ * (the reporter's rule: `last_updated` provenance exclusion, declared-leaf
4756
+ * materialization). A schema-declared key (a `FIELD_CLASSIFICATION` row)
4757
+ * that is absent before and present after is the seam catching a document
4758
+ * up, not the caller's action, the same provenance rule the reporter
4759
+ * applies. A key already present that the seam re-derives to a new value
4760
+ * (for example `progress.*` under `resync`) IS counted: §8.3 bounds what the
4761
+ * write changed, whoever computed it.
4762
+ * - A frontmatter key is declared when an assertion names it exactly, or when
4763
+ * it is one of the schema-declared leaves (`declaredLeavesOf`) of a key an
4764
+ * assertion names. Declaring `progress` therefore covers the closed set of
4765
+ * `progress.*` rows `FIELD_CLASSIFICATION` lists, the frontmatter analogue of
4766
+ * a `target: 'section'` assertion covering its child sections. Nothing else
4767
+ * widens: a declared leaf (`progress.percent`) never covers a sibling leaf,
4768
+ * and a dotted key outside the schema's leaf set is never covered by its
4769
+ * prefix.
4770
+ * - `scope: 'broad'` puts the WHOLE frontmatter in scope. That is ADR-4629
4771
+ * §8.3's own worked case (`milestoneSwitch` declares a whole-frontmatter
4772
+ * scope). The body stays bounded: broad is audited, not exempted.
4773
+ * - `kind: 'rebuild'` (`cmdStateSync`, `REGENERATE_STATE`) is unbounded by
4774
+ * contract (ADR-3408 §8.3; ADR-4629 §8.3 names those two and no more), so
4775
+ * §8.3 is skipped for it. §8.2 still applies.
4776
+ *
4777
+ * `Current Position` is always read as a whole section, in §8.2 and §8.3 alike,
4778
+ * matching `readStateTarget`'s long-standing special case.
4779
+ */
4780
+ function verifyStateWriteIntent(intent, preContent, postContent, statePath, bodyDeltas) {
4781
+ const pre = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, preContent).content;
4782
+ const post = (0, shell_command_projection_cjs_1.normalizeContent)(statePath, postContent).content;
4783
+ const preFm = extractFrontmatter(pre, statePath);
4784
+ const postFm = extractFrontmatter(post, statePath);
4785
+ const preBody = stripFrontmatter(pre);
4786
+ const postBody = stripFrontmatter(post);
4787
+ const isSection = (a) => a.target === 'section' || a.field === 'Current Position';
4788
+ const sectionTargets = new Set();
4789
+ const fieldTargets = new Set();
4790
+ for (const a of intent.assertions) {
4791
+ if (isSection(a))
4792
+ sectionTargets.add(a.field);
4793
+ else
4794
+ fieldTargets.add(a.field);
4795
+ }
4796
+ // §8.2 — required assertions.
4797
+ const missedRequired = [];
4798
+ for (const a of intent.assertions) {
4799
+ if (a.requirement !== 'required')
4800
+ continue;
4801
+ const targets = isSection(a) ? new Set([a.field]) : NO_SECTION_TARGETS;
4802
+ const actual = readStateTarget(postFm, postBody, a.field, targets);
4803
+ const landed = a.value !== undefined
4804
+ ? actual !== null && actual.trim() === a.value.trim()
4805
+ : actual !== null && stateTargetChanged(preFm, preBody, postFm, postBody, a.field, targets);
4806
+ if (!landed)
4807
+ missedRequired.push(a.field);
4808
+ }
4809
+ // §8.3 — bounded mutation.
4810
+ const outOfScope = [];
4811
+ if (intent.kind !== 'rebuild') {
4812
+ if (intent.scope !== 'broad') {
4813
+ const declared = (key) => {
4814
+ if (fieldTargets.has(key))
4815
+ return true;
4816
+ const dot = key.indexOf('.');
4817
+ if (dot <= 0)
4818
+ return false;
4819
+ const parent = key.slice(0, dot);
4820
+ return fieldTargets.has(parent) && declaredLeavesOf(parent).includes(key);
4821
+ };
4822
+ if (isUnparseableFrontmatter(preFm) || isUnparseableFrontmatter(postFm)) {
4823
+ // An unparseable block has no keys to diff; judge the raw region.
4824
+ if (stateFrontmatterRegion(pre) !== stateFrontmatterRegion(post)) {
4825
+ outOfScope.push({ region: 'frontmatter', name: '(unparseable block)' });
4826
+ }
4827
+ }
4828
+ else {
4829
+ const schemaMaterialized = (key) => Object.prototype.hasOwnProperty.call(FIELD_CLASSIFICATION, key)
4830
+ && resolveFrontmatterPath(preFm, key) === STATE_FIELD_ABSENT
4831
+ && resolveFrontmatterPath(postFm, key) !== STATE_FIELD_ABSENT;
4832
+ // Non-body-sourced keys: `computeChangedFrontmatterFields` skips body-sourced
4833
+ // keys entirely when handed no `bodyDeltas`; those are judged just below.
4834
+ for (const key of computeChangedFrontmatterFields(preFm, postFm, undefined)) {
4835
+ if (!declared(key) && !schemaMaterialized(key))
4836
+ outOfScope.push({ region: 'frontmatter', name: key });
4837
+ }
4838
+ const topKeys = new Set([...Object.keys(preFm), ...Object.keys(postFm)]);
4839
+ for (const key of topKeys) {
4840
+ if (stateTransitionMod.getFrontmatterBodySource(key) === null)
4841
+ continue;
4842
+ const before = resolveFrontmatterPath(preFm, key);
4843
+ const after = resolveFrontmatterPath(postFm, key);
4844
+ if (!stateFieldValuesDiffer(before, after))
4845
+ continue;
4846
+ if (before === STATE_FIELD_ABSENT)
4847
+ continue; // back-fill (the #1264 case), not the caller's action
4848
+ const delta = bodyDeltas ? bodyDeltas[key] : undefined;
4849
+ const bodyMoved = delta !== undefined
4850
+ && stateFieldValuesDiffer(delta.pre ?? STATE_FIELD_ABSENT, delta.post ?? STATE_FIELD_ABSENT);
4851
+ if (bodyMoved || declared(key))
4852
+ continue;
4853
+ outOfScope.push({ region: 'frontmatter', name: key });
4854
+ }
4855
+ }
4856
+ }
4857
+ const preUnits = collectStateBodyUnits(preBody);
4858
+ const postUnits = collectStateBodyUnits(postBody);
4859
+ const owners = new Map();
4860
+ for (const field of fieldTargets) {
4861
+ const owner = stateFieldOwnerUnit(postUnits, field) ?? stateFieldOwnerUnit(preUnits, field);
4862
+ if (owner !== null)
4863
+ owners.set(owner, [...(owners.get(owner) ?? []), field]);
4864
+ }
4865
+ const sectionScope = new Set([
4866
+ ...stateSectionScopeUnits(preUnits, sectionTargets),
4867
+ ...stateSectionScopeUnits(postUnits, sectionTargets),
4868
+ ]);
4869
+ for (const key of new Set([...preUnits.keys(), ...postUnits.keys()])) {
4870
+ const before = preUnits.get(key);
4871
+ const after = postUnits.get(key);
4872
+ if (before !== undefined && after !== undefined && before.text === after.text)
4873
+ continue;
4874
+ const unit = (after ?? before);
4875
+ if (sectionScope.has(key))
4876
+ continue;
4877
+ if (before !== undefined && after !== undefined) {
4878
+ let replayed = before.text;
4879
+ for (const field of owners.get(key) ?? []) {
4880
+ const next = (0, state_document_cjs_1.stateExtractField)(after.text, field);
4881
+ if (next === null)
4882
+ continue;
4883
+ const applied = (0, state_document_cjs_1.stateReplaceField)(replayed, field, next);
4884
+ if (applied !== null)
4885
+ replayed = applied;
4886
+ }
4887
+ if (replayed.trim() === after.text)
4888
+ continue;
4889
+ }
4890
+ outOfScope.push(unit.region);
4891
+ }
4892
+ const preOrder = [...preUnits.keys()].filter((k) => postUnits.has(k));
4893
+ const postOrder = [...postUnits.keys()].filter((k) => preUnits.has(k));
4894
+ if (preOrder.join('\n') !== postOrder.join('\n'))
4895
+ outOfScope.push({ region: 'order', name: '' });
4896
+ }
4897
+ const reasons = [];
4898
+ if (outOfScope.length > 0)
4899
+ reasons.push(STATE_WRITE_INTENT_FAILURE.OUT_OF_SCOPE);
4900
+ if (missedRequired.length > 0)
4901
+ reasons.push(STATE_WRITE_INTENT_FAILURE.REQUIRED_ASSERTION_MISSED);
4902
+ return Object.freeze({
4903
+ ok: reasons.length === 0,
4904
+ reasons: Object.freeze(reasons),
4905
+ outOfScope: Object.freeze(outOfScope.map((r) => Object.freeze({ ...r }))),
4906
+ missedRequired: Object.freeze(missedRequired),
4907
+ });
4908
+ }
4909
+ const NO_UPDATED_FIELDS = Object.freeze([]);
4910
+ /**
4911
+ * ADR-4629 §8.2 + §8.3: apply a declared `StateWriteIntent` through the one
4912
+ * write seam and fail loud instead of reporting success.
4913
+ *
4914
+ * Under ONE lock (the same `acquireStateLock` `readModifyWriteStateMd` holds):
4915
+ * read -> transform -> `syncAndPreserveStateMd` (the owned composition,
4916
+ * driven by the intent's own `resync` / `deriveProgressKeys` /
4917
+ * `explicitProgressField`) -> verify the would-be content -> write only if
4918
+ * it verified -> re-read -> confirm the re-read file is exactly the verified
4919
+ * content (§8.2 verifies against the re-read file) -> report `updated[]`
4920
+ * from the measured disk delta.
4921
+ * A §8.2/§8.3 violation is refused BEFORE the write, so the file is left
4922
+ * untouched. The re-read check is the one failure found AFTER the write: if
4923
+ * the re-read does not match what was verified, the result is
4924
+ * `reasons: ['reread_mismatch']` with `written: true`. The file is NOT restored
4925
+ * on that arm: the write reached disk but is not verified, and the result says
4926
+ * so rather than attempting a restore through a write path that would itself
4927
+ * normalize the bytes. Its `outOfScope` / `missedRequired` are empty, because
4928
+ * the candidate content itself verified; what failed is the disk round-trip.
4929
+ *
4930
+ * "Loud" means the result, not an exception: a verification failure is a
4931
+ * frozen `ok: false` value naming each reason, never a success payload.
4932
+ * Turning that into a non-zero exit is the CLI caller's job, which lands with
4933
+ * the first migrated caller (C3+). Construction errors (no intent, a rebuild
4934
+ * intent) throw, like `createStateTransaction`'s.
4935
+ *
4936
+ * A transform whose output is byte-identical to its input is the #948 no-op:
4937
+ * nothing is written, but the intent is still verified, so a `required`
4938
+ * assertion that expected a change reports as missed rather than passing.
4939
+ *
4940
+ * Scope fence (C2): open-kind intents only. The two `rebuild` writers stay on
4941
+ * `writeStateMd`, the sanctioned exception path (ADR-3408 §8.3). The intent's
4942
+ * `snapshot` is not compared against the file read under the lock;
4943
+ * optimistic concurrency on the prior value is outside ADR-4629 §8 as written.
4944
+ */
4945
+ function applyStateWriteIntent(statePath, intent, transformFn, cwd, options = {}, clock) {
4946
+ // Checked on an `unknown` view: `Array.isArray` on the typed ReadonlyArray would narrow it to `any[]`.
4947
+ if (intent === null || typeof intent !== 'object' || !Array.isArray(intent.assertions)) {
4948
+ const err = new Error('applyStateWriteIntent: a StateWriteIntent is required (build it with createStateWriteIntent). ' +
4949
+ 'Per ADR-4629 §8.1, an absent intent is a construction failure, not an unverified write.');
4950
+ err.code = 'STATE_WRITE_INTENT_REQUIRED';
4951
+ throw err;
4952
+ }
4953
+ if (intent.kind === 'rebuild') {
4954
+ const err = new Error('applyStateWriteIntent: a rebuild intent cannot travel the preservation seam. The two rebuild ' +
4955
+ 'writers (cmdStateSync, REGENERATE_STATE) stay on writeStateMd per ADR-3408 §8.3; ADR-4629 §8.3 ' +
4956
+ 'treats them as unbounded by contract.');
4957
+ err.code = 'STATE_TRANSACTION_KIND_INVALID';
4958
+ throw err;
4959
+ }
4960
+ const lockPath = acquireStateLock(statePath, clock);
4961
+ try {
4962
+ const pre = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
4963
+ const transformed = transformFn(pre);
4964
+ const unwritten = (verification) => Object.freeze({ ...verification, written: false, updated: NO_UPDATED_FIELDS });
4965
+ if (transformed === pre) {
4966
+ return unwritten(verifyStateWriteIntent(intent, pre, pre, statePath));
4967
+ }
4968
+ const preWriteState = {};
4969
+ const candidate = syncAndPreserveStateMd(pre, transformed, statePath, cwd, {
4970
+ resync: intent.resync,
4971
+ deriveProgressKeys: intent.deriveProgressKeys,
4972
+ explicitProgressField: intent.explicitProgressField,
4973
+ authoritativeFm: options.authoritativeFm,
4974
+ preWriteState,
4975
+ });
4976
+ const verification = verifyStateWriteIntent(intent, pre, candidate, statePath, preWriteState.bodyDeltas);
4977
+ if (!verification.ok)
4978
+ return unwritten(verification);
4979
+ (0, shell_command_projection_cjs_1.platformWriteSync)(statePath, candidate);
4980
+ const reread = (0, shell_command_projection_cjs_1.platformReadSync)(statePath) || '';
4981
+ if (reread !== (0, shell_command_projection_cjs_1.normalizeContent)(statePath, candidate).content) {
4982
+ return Object.freeze({
4983
+ ok: false,
4984
+ reasons: Object.freeze([STATE_WRITE_INTENT_FAILURE.REREAD_MISMATCH]),
4985
+ outOfScope: verification.outOfScope,
4986
+ missedRequired: verification.missedRequired,
4987
+ written: true,
4988
+ updated: NO_UPDATED_FIELDS,
4989
+ });
4990
+ }
4991
+ const sectionTargets = new Set(intent.assertions.filter((a) => a.target === 'section' || a.field === 'Current Position').map((a) => a.field));
4992
+ const updated = reconcileReportedFields(statePath, preWriteState, intent.assertions.map((a) => a.field), [], sectionTargets);
4993
+ return Object.freeze({ ...verification, written: true, updated: Object.freeze(updated) });
4994
+ }
4995
+ finally {
4996
+ releaseStateLock(lockPath);
4997
+ }
4998
+ }
4617
4999
  function cmdStateJson(cwd, raw) {
4618
5000
  const statePath = planningPaths(cwd).state;
4619
5001
  if (!node_fs_1.default.existsSync(statePath)) {
@@ -6348,6 +6730,12 @@ module.exports = {
6348
6730
  // pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against
6349
6731
  // itself.
6350
6732
  _bodyLabelFor: bodyLabelFor,
6733
+ // ADR-4629 §8.2/§8.3 (epic #4629, C2 #4866): the verifying executor and its
6734
+ // pure verifier. No production caller constructs a StateWriteIntent yet (C3+),
6735
+ // so these are reached only by their positive-control tests.
6736
+ applyStateWriteIntent,
6737
+ _verifyStateWriteIntent: verifyStateWriteIntent,
6738
+ STATE_WRITE_INTENT_FAILURE,
6351
6739
  // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated
6352
6740
  // steal decision is exercised without real pids. Mirrors capability-lock.cts.
6353
6741
  _setLockProbes(probes) {
@@ -147,7 +147,24 @@ function resolveGlobalCandidate(fs, env, home, preferred) {
147
147
  /**
148
148
  * Pure resolver. Returns { installedVersion, scope, runtime, gsdDir }.
149
149
  */
150
- function resolveUpdateContext({ home, cwd, env = {}, fs, preferredConfigDir = '', preferredRuntime = '', }) {
150
+ function resolveUpdateContext(opts) {
151
+ const base = resolveBaseUpdateContext(opts);
152
+ if (base.runtime !== 'opencode' || !base.gsdDir)
153
+ return base;
154
+ // The install records which OpenCode generation it targets; surface it when present.
155
+ const raw = opts.fs.readFile(node_path_1.default.join(base.gsdDir, 'gsd-install-state.json'));
156
+ if (raw == null)
157
+ return base;
158
+ try {
159
+ const hg = JSON.parse(raw).hostGeneration;
160
+ if (hg && hg.runtime === 'opencode' && (hg.generation === 1 || hg.generation === 2)) {
161
+ return { ...base, hostGeneration: hg.generation };
162
+ }
163
+ }
164
+ catch { /* an unreadable state file contributes nothing */ }
165
+ return base;
166
+ }
167
+ function resolveBaseUpdateContext({ home, cwd, env = {}, fs, preferredConfigDir = '', preferredRuntime = '', }) {
151
168
  // Expand a leading `~/` before any probe.
152
169
  preferredConfigDir = expandHome(preferredConfigDir, home);
153
170
  const preferred = preferredRuntime || inferPreferredRuntime({ fs, env, preferredConfigDir });