refactor(#1791): ADR-1769 Phase 6 — patch migration + curated current_phase_name preserve (#1695) (#1792)
Migrate cmdStatePatch (state.cts) onto the STATE.md Transition Module substrate and close the curated-field clobber bug class #1743/#1695 (ADR-1769, epic #1769). - Add {kind: 'patch'} to StateTransitionIntent, with patchCore in src/state-transition.cts. Applies each caller-supplied {field:value} pair via stateReplaceField over the full content (body + frontmatter), tracking updated vs. failed. data.updated/data.failed mirror the CLI output shape. - Collapse cmdStatePatch to a transitionCore dispatch. Field-name validation (security) and the resync-progress decision stay in the adapter. - #1695/#1743 fix: extend the #1230 delta heuristic in readModifyWriteStateMd to the curated current_phase_name, table-driven via getFieldClassification('current_phase_name').preservation === 'preserve-always'. When a write does NOT change the body Phase: source line, the curated frontmatter value wins over syncStateFrontmatter's body re-derivation (which harvests a wrong parenthetical aside — #1695). begin/planned/complete-phase rewrite their body Phase line, so the delta does not fire for them and current_phase_name still advances. - Regression: bug-1695-state-patch-clobbers-phase-name.test.cjs — unrelated patch preserves curated current_phase_name; patching the Phase source advances. All 70 transition + 493 regression tests pass (state/phase/milestone + #905/#397/#3242 lineages). Closes #1791
This commit is contained in:
@@ -179,8 +179,9 @@ export type StateTransitionIntent =
|
||||
version: string;
|
||||
/** Resolved runtime slash command for the Operator Next Steps hint (e.g. '/gsd:new-milestone'). */
|
||||
nextMilestoneCommand: string;
|
||||
};
|
||||
// Phases 6–7 add the remaining intent kinds to this discriminated union.
|
||||
}
|
||||
| { kind: 'patch'; patches: Record<string, string> };
|
||||
// Phase 7 adds the remaining intent kinds to this discriminated union.
|
||||
|
||||
export type StateTransitionResult = {
|
||||
content: string;
|
||||
@@ -221,6 +222,8 @@ export function transitionCore(
|
||||
return milestoneSwitchCore(content, intent, deps);
|
||||
case 'milestoneComplete':
|
||||
return milestoneCompleteCore(content, intent, deps);
|
||||
case 'patch':
|
||||
return patchCore(content, intent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1128,3 +1131,46 @@ function milestoneCompleteCore(
|
||||
|
||||
return { content: reassemble(body), updated };
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// patch — intent implementation (Phase 6)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Apply a `patch` transition to STATE.md content.
|
||||
*
|
||||
* Migrates `cmdStatePatch` (state.cts) onto the substrate. Applies each
|
||||
* caller-supplied `{field: value}` pair via `stateReplaceField` over the full
|
||||
* content (body + frontmatter — patch can target either), tracking which fields
|
||||
* were updated vs. not found.
|
||||
*
|
||||
* The curated-field preservation that fixes #1743/#1695 is NOT in this core —
|
||||
* it lives in `readModifyWriteStateMd`'s post-sync delta (table-driven via
|
||||
* `getFieldClassification('current_phase_name').preservation === 'preserve-always'`).
|
||||
* `patch` consulting the table "refuses to overwrite" curated fields implicitly:
|
||||
* when the patch does not change a curated field's body source line, the
|
||||
* existing frontmatter value wins over the sync re-derivation. The adapter
|
||||
* still owns field-name validation (security) and the resync-progress decision.
|
||||
*
|
||||
* `data.updated` / `data.failed` mirror the pre-migration CLI output shape.
|
||||
*/
|
||||
function patchCore(
|
||||
content: string,
|
||||
intent: { kind: 'patch'; patches: Record<string, string> },
|
||||
): StateTransitionResult {
|
||||
const updated: string[] = [];
|
||||
const failed: string[] = [];
|
||||
let result = content;
|
||||
|
||||
for (const [field, value] of Object.entries(intent.patches)) {
|
||||
const replaced = stateReplaceField(result, field, value);
|
||||
if (replaced !== null) {
|
||||
result = replaced;
|
||||
updated.push(field);
|
||||
} else {
|
||||
failed.push(field);
|
||||
}
|
||||
}
|
||||
|
||||
return { content: result, updated, data: { updated, failed } };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ const { extractFrontmatter, reconstructFrontmatter } = frontmatter;
|
||||
import scanPhasePlans = require('./plan-scan.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import stateTransitionMod = require('./state-transition.cjs');
|
||||
const { transitionCore } = stateTransitionMod;
|
||||
const { transitionCore, getFieldClassification } = stateTransitionMod;
|
||||
type StateTransitionIntent = stateTransitionMod.StateTransitionIntent;
|
||||
type StateTransitionDeps = stateTransitionMod.StateTransitionDeps;
|
||||
import {
|
||||
@@ -397,21 +397,19 @@ function cmdStatePatch(cwd: string, patches: Record<string, string>, raw: boolea
|
||||
|
||||
const statePath = planningPaths(cwd).state;
|
||||
try {
|
||||
const results: { updated: string[]; failed: string[] } = { updated: [], failed: [] };
|
||||
const shouldResync = shouldResyncStateProgress(Object.keys(patches));
|
||||
|
||||
// Use atomic read-modify-write to prevent lost updates from concurrent agents
|
||||
// ADR-1769 Phase 6: dispatches to the STATE.md Transition Module. The
|
||||
// per-patch stateReplaceField loop is the pure `patchCore` in
|
||||
// src/state-transition.cts. readModifyWriteStateMd still owns the lock, the
|
||||
// #1230/#1264 post-sync preservation, AND the #1695 curated-current_phase_name
|
||||
// delta (table-driven) that this phase adds. Field-name validation (security)
|
||||
// and the resync-progress decision stay in this adapter.
|
||||
let results: { updated: string[]; failed: string[] } = { updated: [], failed: [] };
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
for (const [field, value] of Object.entries(patches)) {
|
||||
const result = stateReplaceField(content, field, value);
|
||||
if (result) {
|
||||
content = result;
|
||||
results.updated.push(field);
|
||||
} else {
|
||||
results.failed.push(field);
|
||||
}
|
||||
}
|
||||
return content;
|
||||
const result = transitionCore(content, { kind: 'patch', patches }, { clock: realClock, progressProvider: () => null });
|
||||
results = (result.data as { updated: string[]; failed: string[] }) ?? results;
|
||||
return result.content;
|
||||
}, cwd, { resync: shouldResync });
|
||||
|
||||
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
||||
@@ -1892,6 +1890,14 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
||||
const preSessionScope = preSessionMatch ? preSessionMatch[1] : preBody;
|
||||
const preBodyStoppedAt = stateExtractField(preSessionScope, 'Stopped At') || stateExtractField(preSessionScope, 'Stopped at');
|
||||
|
||||
// ADR-1769 Phase 6 / #1743 / #1695: snapshot the body source for the curated
|
||||
// current_phase_name (the `Phase:` line parseProsePhaseField harvests). When
|
||||
// this write does NOT change that line, the curated frontmatter value must
|
||||
// win over syncStateFrontmatter's body re-derivation (which can harvest a
|
||||
// wrong parenthetical aside — #1695). Gated by the field-classification
|
||||
// table's preserve-always row so the rule lives in one place.
|
||||
const preBodyPhaseSource = stateExtractField(preBody, 'Phase');
|
||||
|
||||
const modified = transformFn(content);
|
||||
|
||||
// Bug #948: no-op guard — if the transform produced no change, do NOT write
|
||||
@@ -1922,6 +1928,9 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
||||
const postSessionMatch = matchSessionSection(postBody);
|
||||
const postSessionScope = postSessionMatch ? postSessionMatch[1] : postBody;
|
||||
const postBodyStoppedAt = stateExtractField(postSessionScope, 'Stopped At') || stateExtractField(postSessionScope, 'Stopped at');
|
||||
// ADR-1769 Phase 6 / #1695: post-transform body Phase source for the
|
||||
// current_phase_name delta comparison.
|
||||
const postBodyPhaseSource = stateExtractField(postBody, 'Phase');
|
||||
|
||||
let mutated = false;
|
||||
const postFm = extractFrontmatter(synced) as Record<string, unknown>;
|
||||
@@ -1963,6 +1972,26 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
// ADR-1769 Phase 6 / #1743 / #1695: same delta heuristic for the curated
|
||||
// current_phase_name. Gated by the field-classification table (preserve-always).
|
||||
// When this write did NOT change the body `Phase:` source line, the curated
|
||||
// frontmatter current_phase_name wins over syncStateFrontmatter's body
|
||||
// re-derivation (parseProsePhaseField can harvest a wrong parenthetical
|
||||
// aside — #1695). begin/planned/complete-phase rewrite their body Phase line,
|
||||
// so the delta does not fire for them and current_phase_name still advances.
|
||||
const phaseNameCls = getFieldClassification('current_phase_name');
|
||||
if (
|
||||
phaseNameCls !== null &&
|
||||
phaseNameCls.preservation === 'preserve-always' &&
|
||||
postBodyPhaseSource === preBodyPhaseSource &&
|
||||
typeof preFmSnapshot['current_phase_name'] === 'string' &&
|
||||
preFmSnapshot['current_phase_name'].length > 0 &&
|
||||
postFm['current_phase_name'] !== preFmSnapshot['current_phase_name']
|
||||
) {
|
||||
postFm['current_phase_name'] = preFmSnapshot['current_phase_name'];
|
||||
mutated = true;
|
||||
}
|
||||
|
||||
if (mutated) {
|
||||
const yamlStr = reconstructFrontmatter(postFm as unknown as Frontmatter);
|
||||
const body = stripFrontmatter(synced);
|
||||
|
||||
Reference in New Issue
Block a user