From f4e5805c9e37c5bee8c0966a6b78eb3bf964762b Mon Sep 17 00:00:00 2001 From: Chengwei Ouyang Date: Sun, 26 Jul 2026 22:31:09 +0800 Subject: [PATCH 1/3] fix: reconcile lifecycle after in-call applications (#12) --- CHANGELOG.md | 12 ++ ...6-post-application-lifecycle-quiescence.md | 92 ++++++++++ docs/v1-verification-matrix.md | 2 +- package.json | 2 +- skills/evolve/manifest.json | 2 +- skills/evolve/references/config-schema.md | 2 +- skills/evolve/references/protocol-v1.md | 7 + skills/evolve/runtime/README.md | 6 + skills/evolve/runtime/lifecycle.mjs | 81 +++++++-- templates/.agent-context/config.yml | 2 +- .../lifecycle/lifecycle-coordinator.test.mjs | 172 ++++++++++++++++++ .../lifecycle-outcome.integration.test.mjs | 125 +++++++++++++ tests/verification/installer-helpers.mjs | 8 +- .../verification/repository-contract.test.mjs | 2 +- 14 files changed, 485 insertions(+), 30 deletions(-) create mode 100644 docs/adr/0006-post-application-lifecycle-quiescence.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3613544..7a5b043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ semantic versions for the Kit independently from the Workspace Schema version. ## [Unreleased] +## [0.5.2] - 2026-07-26 + +### Fixed + +- Lifecycle reconciliation now performs bounded post-application passes so one + call cannot report `settled` when its own exact application has already made + an earlier sibling proposal stale. +- Coordinator results are independent of proposal filename order while + unrelated approval-waiting proposals remain non-blocking. +- The real Coordinator-to-Outcome path now verifies that a post-application + sibling blocker cannot produce an applied-success receipt. + ## [0.5.1] - 2026-07-19 ### Added diff --git a/docs/adr/0006-post-application-lifecycle-quiescence.md b/docs/adr/0006-post-application-lifecycle-quiescence.md new file mode 100644 index 0000000..b87992d --- /dev/null +++ b/docs/adr/0006-post-application-lifecycle-quiescence.md @@ -0,0 +1,92 @@ +# ADR-0006: Post-application lifecycle quiescence + +- Status: accepted +- Date: 2026-07-26 +- Decision owners: repository maintainer and user +- Extends: ADR-0004 and ADR-0005 + +## Context + +The Lifecycle Coordinator originally inspected each non-terminal proposal once +in filename order. An exact automatic plan could apply late in that pass after +an earlier approval-waiting proposal had already been classified against the +old target bytes. The call then returned `settled`, although its own write had +made the earlier proposal stale. Repeating the same call immediately returned +`blocked / target_state_changed`. + +The defect is broader than two proposals sharing one file: an approved config +change can also alter the policy or domain facts used to evaluate another +proposal. Correctness therefore cannot depend on filename order or a +same-target special case. + +## Decision + +### 1. Keep one external Coordinator interface + +The existing seam remains: + +~~~js +reconcileWorkspaceProposalLifecycles({ workspaceRoot }) +~~~ + +No pass counter, dependency graph, semantic ordering, or new public action is +added. Callers continue to receive one content-safe outcome per inspected +proposal and `inspectedCount === outcomes.length`. + +### 2. Reconcile a stable proposal cohort to post-application quiescence + +The coordinator captures one stable candidate-file cohort after acquiring its +lifecycle lock. It then: + +1. reads and validates the current source of every non-terminal candidate; +2. performs the existing exact lifecycle actions; +3. retains the latest outcome for each candidate; +4. starts another pass whenever a valid action reaches `applied`; and +5. stops after a pass performs no new successful application. + +An applied transition remains in the returned outcome set after its proposal +becomes terminal. A still-live sibling is replaced by its newest classification. +Workspace status is derived only from that final outcome set. + +The loop is bounded by the stable cohort: every continuing pass terminalizes at +least one previously non-terminal proposal, so one final observation pass is +sufficient after at most one application per candidate. + +### 3. Limit the guarantee to Coordinator-owned mutations + +Quiescence means that the return value reflects the state after the +coordinator's own successful applications in that call. It does not claim to +freeze targets against unrelated external writers. Existing hashes, locks, +compare-and-swap proposal writes, and fail-closed target inspection continue to +handle concurrent or later changes. + +### 4. Preserve semantic ownership + +The coordinator may classify a newly stale plan as +`regenerate_required / target_state_changed`. It does not choose proposal +ordering, merge plans, rewrite target meaning, or create a replacement. Those +remain Agent responsibilities. + +## Consequences + +- One call can no longer report `settled` when its own successful application + has already made a sibling lifecycle-blocking. +- Results no longer depend on whether the automatic or approval-waiting + proposal sorts first. +- Unrelated approval-waiting proposals remain non-blocking. +- The Outcome Interface receives complete final sibling evidence and therefore + cannot publish a false applied-success receipt. +- Runtime work can include more than one proposal scan, but no new filesystem + or semantic surface is exposed to callers. + +## Rejected alternatives + +- **Preflight every same-target pair as blocking**: file-level overlap is too + coarse for shared context files and would disable safe automatic additions + that are semantically unrelated. +- **Rescan only same-target siblings**: config and domain changes can affect + proposals without sharing their target path. +- **Run one read-only check after the pass**: this would add a second + classification implementation or fail to settle newly eligible exact work. +- **Move ordering or merge decisions into the Commit Kernel**: the kernel does + not own proposal semantics or lifecycle orchestration. diff --git a/docs/v1-verification-matrix.md b/docs/v1-verification-matrix.md index 17e6fcd..6ffa551 100644 --- a/docs/v1-verification-matrix.md +++ b/docs/v1-verification-matrix.md @@ -15,7 +15,7 @@ future changes do not turn documentation claims into untested promises. | Scope | Workspace is the only active write scope; user-global is a sanitized, approved handoff. | proposal fixtures and validator; `references/protocol-v1.md` | | Write policy | Only `propose` and `auto`; new workspaces default to auto, existing config is preserved, and every automatic write still requires one complete live v1 config plus all target, domain, risk, health, and privacy gates. | default-policy contract; shared production config validator; installer preservation tests; kernel live-config and auto-gate tests | | Approval lifecycle | Eligible auto plans complete in the current Agent turn with a `policy_auto` Decision and one non-blocking receipt. `$evolve approve` handles exceptions and binds a complete persisted PatchPlan, including its semantic operation, to the exact external `planHash`. | auto-default Kernel outcome; applied `policy_auto` demo aggregate; fresh-Agent acceptance record; recomputed proposal/Decision/Attempt and exact-approval tests | -| Lifecycle reconciliation | Unfinished exact auto or approved plans resume only from all-before state; all-after without an applied audit, mixed state, and semantic target drift fail closed. Current approval-only proposals remain non-blocking. A never-applied stale approval terminates only after a recognized conflict and a valid named replacement exists. | production proposal validator and shared auto-eligibility predicate; lifecycle coordinator, proposal-store retry, approval-waiting tighten, and target-inspection behavior tests; lock, idempotency, stale replacement, and content-safe result assertions | +| Lifecycle reconciliation | Unfinished exact auto or approved plans resume only from all-before state; all-after without an applied audit, mixed state, and semantic target drift fail closed. After an exact application, a bounded follow-up pass makes the result reflect the coordinator's own final target and policy state independent of filename order. Current unrelated approval-only proposals remain non-blocking. A never-applied stale approval terminates only after a recognized conflict and a valid named replacement exists. | production proposal validator and shared auto-eligibility predicate; lifecycle coordinator fixed-point, proposal-store retry, approval-waiting tighten, and target-inspection behavior tests; real Outcome integration; lock, idempotency, stale replacement, and content-safe result assertions | | Observable delivery | After a verified high-signal repair, the Agent supplies semantic detect/propose results and the Outcome Interface maps exact Coordinator evidence to apply. Only legal state families produce one content-safe three-stage receipt; no-trigger tasks stay silent and create no no-op proposal. | Outcome unit and real-Coordinator integration tests; shared Codex/Claude adapter contract; fresh-Agent positive and negative acceptance record | | Installation | Agent resolves semantics; Bootstrap plans deterministic files, validates the complete config envelope without Node, and never edits instructions. | shared PowerShell/Bash invalid/valid config, dry-run/apply/idempotency, and guidance-preservation tests | | Runtime capability | Bootstrap and propose do not require Node; the default auto path uses the Node kernel or explicitly downgrades with one blocking reason. | native installer adapters; skill and adapter auto-default contract; kernel tests | diff --git a/package.json b/package.json index c0b602a..eda5a26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-context-patch", - "version": "0.5.1", + "version": "0.5.2", "private": true, "description": "Turn AI agent mistakes into durable project context patches.", "type": "module", diff --git a/skills/evolve/manifest.json b/skills/evolve/manifest.json index af08cc4..acfd907 100644 --- a/skills/evolve/manifest.json +++ b/skills/evolve/manifest.json @@ -1,5 +1,5 @@ { "kit": "agent-context-patch", - "version": "0.5.1", + "version": "0.5.2", "schemaVersion": 1 } diff --git a/skills/evolve/references/config-schema.md b/skills/evolve/references/config-schema.md index 4d78e5b..724d59d 100644 --- a/skills/evolve/references/config-schema.md +++ b/skills/evolve/references/config-schema.md @@ -17,7 +17,7 @@ fixtures. A file that merely says `schema_version: 1` is invalid, not current. ~~~yaml schema_version: 1 -created_with_kit_version: "0.5.1" +created_with_kit_version: "0.5.2" last_migrated_with_kit_version: null context_write_policy: auto diff --git a/skills/evolve/references/protocol-v1.md b/skills/evolve/references/protocol-v1.md index c768654..c9a8508 100644 --- a/skills/evolve/references/protocol-v1.md +++ b/skills/evolve/references/protocol-v1.md @@ -140,6 +140,13 @@ and workspace-relative targets. It never returns target or PatchPlan content or an absolute path. It never generates wording, replaces a stale plan, creates a new proposal, or claims to repair an unknown audit gap. +When an exact plan reaches `applied`, reconciliation repeats over the stable +proposal cohort until one pass performs no new successful application. Each +proposal contributes only its latest outcome, while an applied transition is +retained after that proposal becomes terminal. The returned status therefore +describes state after the coordinator's own writes rather than the order in +which proposal filenames were inspected. + A result may be `settled` while listing `approval_required`: ordinary current approval-only proposals are intentionally waiting for review and do not block unrelated evolve workflows. Unsafe auto, stale, mixed, malformed, or audit-gap diff --git a/skills/evolve/runtime/README.md b/skills/evolve/runtime/README.md index 6b733ff..fd06eff 100644 --- a/skills/evolve/runtime/README.md +++ b/skills/evolve/runtime/README.md @@ -95,6 +95,12 @@ proposal ID, before/after status, action, machine-readable reason, and relative targets. A workspace-level failure may add `blockingReason`. No outcome includes proposal prose, PatchPlan content, target content, or an absolute path. +If reconciliation applies exact work, it re-reads the stable proposal cohort +until a pass performs no new successful application. The final result therefore +reflects target and policy state after the coordinator's own writes, independent +of proposal filename order. It retains one latest outcome per inspected +proposal, including the applied transition for a proposal that became terminal. + `status: settled` means no unsafe mechanical lifecycle gap remains. It can still contain `approval_required` outcomes; those proposals are intentionally waiting for informed human review and do not block unrelated reconciliation, weekly diff --git a/skills/evolve/runtime/lifecycle.mjs b/skills/evolve/runtime/lifecycle.mjs index 81201eb..9482b5a 100644 --- a/skills/evolve/runtime/lifecycle.mjs +++ b/skills/evolve/runtime/lifecycle.mjs @@ -13,6 +13,7 @@ import { applyPatchPlan, sha256Text } from "./index.mjs"; import { inspectPatchPlanTargets } from "./internal.mjs"; import { deriveLifecycleReconciliationStatus, + isAppliedLifecycleOutcome, isLifecycleIdentifier, isTerminalProposalStatus, } from "./lifecycle-contract.mjs"; @@ -64,31 +65,71 @@ async function reconcileWhileLocked({ workspaceRoot, proposalsRoot, missing }) { }; } const entries = await readdir(proposalsRoot, { withFileTypes: true }); + const sortedEntries = entries.sort((left, right) => + compareNames(left.name, right.name), + ); + const candidateCount = sortedEntries.filter((entry) => + isProposalCandidate(entry.name), + ).length; + const outcomesByEntry = new Map(); + + for (let pass = 0; pass <= candidateCount; pass += 1) { + const passOutcomes = await reconcilePass({ + workspaceRoot, + proposalsRoot, + entries: sortedEntries, + }); + for (const { entryName, outcome } of passOutcomes) { + outcomesByEntry.set(entryName, outcome); + } + if ( + !passOutcomes.some(({ outcome }) => isAppliedLifecycleOutcome(outcome)) + ) { + const outcomes = [...outcomesByEntry.values()]; + const status = deriveLifecycleReconciliationStatus(outcomes); + if (status === undefined) throw new TypeError("invalid_lifecycle_outcome"); + return { + status, + inspectedCount: outcomes.length, + outcomes, + }; + } + } + + throw new TypeError("lifecycle_reconciliation_not_quiescent"); +} + +async function reconcilePass({ workspaceRoot, proposalsRoot, entries }) { const outcomes = []; const records = []; const proposalsById = new Map(); const duplicateIds = new Set(); - let inspectedCount = 0; - for (const entry of entries.sort((left, right) => compareNames(left.name, right.name))) { + for (const entry of entries) { if (!isProposalCandidate(entry.name)) continue; const proposalPath = join(proposalsRoot, entry.name); if (!entry.isFile() || entry.isSymbolicLink()) { - inspectedCount += 1; - outcomes.push(unsafeProposalOutcome(entry.name)); + outcomes.push({ + entryName: entry.name, + outcome: unsafeProposalOutcome(entry.name), + }); continue; } const sourceRead = await readProposalUtf8(proposalPath); if (sourceRead.problem) { - inspectedCount += 1; - outcomes.push(invalidProposalOutcome(entry.name, sourceRead.problem)); + outcomes.push({ + entryName: entry.name, + outcome: invalidProposalOutcome(entry.name, sourceRead.problem), + }); continue; } const inspected = inspectProposalDocument(sourceRead.source, entry.name); if (inspected.failures.length > 0) { - inspectedCount += 1; - outcomes.push(invalidProposalOutcome(entry.name, "invalid_proposal")); + outcomes.push({ + entryName: entry.name, + outcome: invalidProposalOutcome(entry.name, "invalid_proposal"), + }); continue; } const record = { @@ -110,29 +151,29 @@ async function reconcileWhileLocked({ workspaceRoot, proposalsRoot, missing }) { const { data } = record.proposal; if (isTerminalProposalStatus(data.status)) continue; - inspectedCount += 1; if (duplicateIds.has(data.id)) { - outcomes.push(invalidProposalOutcome(record.name, "duplicate_proposal_id")); + outcomes.push({ + entryName: record.name, + outcome: invalidProposalOutcome( + record.name, + "duplicate_proposal_id", + ), + }); continue; } - outcomes.push( - await reconcileProposal({ + outcomes.push({ + entryName: record.name, + outcome: await reconcileProposal({ workspaceRoot, proposalPath: record.proposalPath, source: record.source, proposal: record.proposal, proposalsById, }), - ); + }); } - const status = deriveLifecycleReconciliationStatus(outcomes); - if (status === undefined) throw new TypeError("invalid_lifecycle_outcome"); - return { - status, - inspectedCount, - outcomes, - }; + return outcomes; } async function reconcileProposal({ diff --git a/templates/.agent-context/config.yml b/templates/.agent-context/config.yml index 3cde9a4..9f2ab52 100644 --- a/templates/.agent-context/config.yml +++ b/templates/.agent-context/config.yml @@ -1,5 +1,5 @@ schema_version: 1 -created_with_kit_version: "0.5.1" +created_with_kit_version: "0.5.2" last_migrated_with_kit_version: null context_write_policy: auto diff --git a/tests/lifecycle/lifecycle-coordinator.test.mjs b/tests/lifecycle/lifecycle-coordinator.test.mjs index 2108a9a..01ba866 100644 --- a/tests/lifecycle/lifecycle-coordinator.test.mjs +++ b/tests/lifecycle/lifecycle-coordinator.test.mjs @@ -184,6 +184,146 @@ test("a current approval-only proposal is actionable without blocking unrelated assert.equal(await readFile(proposalPath, "utf8"), proposal); }); +test("reconciliation reports a sibling made stale by an auto apply in the same call", async (t) => { + const workspaceRoot = await createWorkspace(t, { policy: "auto" }); + const before = "# Project Profile\n\nShared baseline.\n"; + await writeFile( + join(workspaceRoot, ".agent-context", "PROJECT_PROFILE.md"), + before, + "utf8", + ); + await writeFile( + join( + workspaceRoot, + ".agent-context", + "proposals", + "a-approval-waiting.md", + ), + await approvalWaitingTightenFixture({ before }), + "utf8", + ); + await writeFile( + join(workspaceRoot, ".agent-context", "proposals", "b-auto-add.md"), + await interruptedAutoUpdateFixture({ + before, + content: "# Project Profile\n\nShared baseline.\nAuto addition.\n", + }), + "utf8", + ); + + const result = await reconcileWorkspaceProposalLifecycles({ workspaceRoot }); + + assert.equal(result.status, "blocked"); + assert.equal(result.inspectedCount, 2); + assert.deepEqual(result.outcomes, [ + { + proposalId: "fixture-approval-waiting", + beforeStatus: "proposed", + afterStatus: "proposed", + targets: [".agent-context/PROJECT_PROFILE.md"], + action: "regenerate_required", + reason: "target_state_changed", + }, + { + proposalId: "fixture-auto-update", + beforeStatus: "proposed", + afterStatus: "applied", + action: "resume_exact_auto", + reason: "applied", + targets: [".agent-context/PROJECT_PROFILE.md"], + }, + ]); +}); + +test("post-application reconciliation is independent of proposal filename order", async (t) => { + const workspaceRoot = await createWorkspace(t, { policy: "auto" }); + const before = "# Project Profile\n\nShared baseline.\n"; + await writeFile( + join(workspaceRoot, ".agent-context", "PROJECT_PROFILE.md"), + before, + "utf8", + ); + await writeFile( + join(workspaceRoot, ".agent-context", "proposals", "a-auto-add.md"), + await interruptedAutoUpdateFixture({ + before, + content: "# Project Profile\n\nShared baseline.\nAuto addition.\n", + }), + "utf8", + ); + await writeFile( + join( + workspaceRoot, + ".agent-context", + "proposals", + "b-approval-waiting.md", + ), + await approvalWaitingTightenFixture({ before }), + "utf8", + ); + + const result = await reconcileWorkspaceProposalLifecycles({ workspaceRoot }); + const outcomesById = new Map( + result.outcomes.map((outcome) => [outcome.proposalId, outcome]), + ); + + assert.equal(result.status, "blocked"); + assert.equal(result.inspectedCount, 2); + assert.equal(outcomesById.get("fixture-auto-update")?.reason, "applied"); + assert.equal( + outcomesById.get("fixture-approval-waiting")?.reason, + "target_state_changed", + ); +}); + +test("post-application reconciliation leaves unrelated approval work actionable", async (t) => { + const workspaceRoot = await createWorkspace(t, { policy: "auto" }); + const profileBefore = "# Project Profile\n\nApproval baseline.\n"; + const indexBefore = "# Project Context Index\n\nCurrent index.\n"; + await writeFile( + join(workspaceRoot, ".agent-context", "PROJECT_PROFILE.md"), + profileBefore, + "utf8", + ); + await writeFile( + join(workspaceRoot, ".agent-context", "PROJECT_CONTEXT_INDEX.md"), + indexBefore, + "utf8", + ); + await writeFile( + join( + workspaceRoot, + ".agent-context", + "proposals", + "a-approval-waiting.md", + ), + await approvalWaitingTightenFixture({ before: profileBefore }), + "utf8", + ); + await writeFile( + join(workspaceRoot, ".agent-context", "proposals", "b-auto-add.md"), + await interruptedAutoUpdateFixture({ + target: ".agent-context/PROJECT_CONTEXT_INDEX.md", + before: indexBefore, + content: "# Project Context Index\n\nCurrent index.\nAuto addition.\n", + }), + "utf8", + ); + + const result = await reconcileWorkspaceProposalLifecycles({ workspaceRoot }); + const outcomesById = new Map( + result.outcomes.map((outcome) => [outcome.proposalId, outcome]), + ); + + assert.equal(result.status, "settled"); + assert.equal(result.inspectedCount, 2); + assert.equal( + outcomesById.get("fixture-approval-waiting")?.action, + "approval_required", + ); + assert.equal(outcomesById.get("fixture-auto-update")?.reason, "applied"); +}); + test("a stale approval-waiting tighten proposal is marked for regeneration before approval", async (t) => { const workspaceRoot = await createWorkspace(t, { policy: "auto" }); const proposalPath = join( @@ -545,6 +685,38 @@ async function approvalWaitingTightenFixture({ before }) { ); } +async function interruptedAutoUpdateFixture({ + target = ".agent-context/PROJECT_PROFILE.md", + before, + content, +}) { + const source = await interruptedAutoFixture(); + const inspected = inspectProposalDocument(source, "auto update fixture base"); + assert.deepEqual(inspected.failures, []); + const plan = structuredClone(inspected.value.plan); + plan.planId = "plan-fixture-auto-update"; + plan.proposalId = "fixture-auto-update"; + plan.operations = [ + { + type: "update", + target, + beforeHash: sha256Text(before), + content, + }, + ]; + const planHash = computePlanHash(plan); + return replacePatchPlan( + source + .replace("id: fixture-valid-auto", "id: fixture-auto-update") + .replace( + " - .agent-context/PROJECT_PROFILE.md", + ` - ${target}`, + ) + .replace(/^plan_hash:[^\r\n]*$/mu, `plan_hash: ${planHash}`), + plan, + ); +} + function replacePatchPlan(source, plan) { const opening = source.indexOf("~~~~json"); assert.notEqual(opening, -1); diff --git a/tests/outcome/lifecycle-outcome.integration.test.mjs b/tests/outcome/lifecycle-outcome.integration.test.mjs index 6aaf474..2c71a4d 100644 --- a/tests/outcome/lifecycle-outcome.integration.test.mjs +++ b/tests/outcome/lifecycle-outcome.integration.test.mjs @@ -4,8 +4,13 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { + computePlanHash, + sha256Text, +} from "../../skills/evolve/runtime/index.mjs"; import { reconcileWorkspaceProposalLifecycles } from "../../skills/evolve/runtime/lifecycle.mjs"; import { finalizeEvolutionOutcome } from "../../skills/evolve/runtime/outcome.mjs"; +import { inspectProposalDocument } from "../../skills/evolve/runtime/proposal.mjs"; const fixtureRoot = join(import.meta.dirname, "..", "verification", "fixtures"); @@ -54,6 +59,103 @@ test("the delivery checkpoint reports applied only after the real coordinator re assert.match(await readFile(proposalPath, "utf8"), /result: applied/u); }); +test("the delivery checkpoint blocks success when the same apply makes a sibling stale", async (t) => { + const workspaceRoot = await mkdtemp(join(tmpdir(), "agent-context-outcome-")); + t.after(() => rm(workspaceRoot, { recursive: true, force: true })); + const contextRoot = join(workspaceRoot, ".agent-context"); + const proposalsRoot = join(contextRoot, "proposals"); + await mkdir(proposalsRoot, { recursive: true }); + await writeFile( + join(contextRoot, "config.yml"), + await readFile( + join(fixtureRoot, "config", "valid-auto-inline.yml"), + "utf8", + ), + "utf8", + ); + const target = ".agent-context/PROJECT_PROFILE.md"; + const before = "# Project Profile\n\nShared baseline.\n"; + await writeFile(join(workspaceRoot, target), before, "utf8"); + + const appliedFixture = await readFile( + join(fixtureRoot, "proposals", "valid-auto.md"), + "utf8", + ); + const parsed = inspectProposalDocument(appliedFixture, "outcome fixture base"); + assert.deepEqual(parsed.failures, []); + const approvalPlan = structuredClone(parsed.value.plan); + Object.assign(approvalPlan, { + planId: "plan-outcome-approval", + proposalId: "outcome-approval", + semanticOperation: "tighten", + requestedPolicy: "auto", + policy: "propose", + policyReason: "semantic_overlap_requires_approval", + risk: "high", + }); + approvalPlan.contextHealth.autoAllowed = false; + approvalPlan.operations = [ + { + type: "update", + target, + beforeHash: sha256Text(before), + content: "# Project Profile\n\nApproval-only change.\n", + }, + ]; + const autoPlan = structuredClone(parsed.value.plan); + Object.assign(autoPlan, { + planId: "plan-outcome-auto", + proposalId: "outcome-auto", + }); + autoPlan.operations = [ + { + type: "update", + target, + beforeHash: sha256Text(before), + content: "# Project Profile\n\nShared baseline.\nAuto addition.\n", + }, + ]; + await writeFile( + join(proposalsRoot, "a-approval.md"), + proposalFixture(appliedFixture, { + id: "outcome-approval", + operation: "tighten", + plan: approvalPlan, + }), + "utf8", + ); + await writeFile( + join(proposalsRoot, "b-auto.md"), + proposalFixture(appliedFixture, { + id: "outcome-auto", + operation: "add", + plan: autoPlan, + }), + "utf8", + ); + + const reconciliation = await reconcileWorkspaceProposalLifecycles({ + workspaceRoot, + }); + const result = finalizeEvolutionOutcome({ + detect: { + status: "candidate", + reason: "failed_verification_later_passed", + }, + propose: { status: "created", reason: "proposal_created" }, + proposalId: "outcome-auto", + reconciliation, + }); + + assert.equal(reconciliation.status, "blocked"); + assert.deepEqual(result.apply, { + status: "blocked", + reason: "workspace_reconciliation_blocked", + }); + assert.equal(result.receipt.kind, "blocked"); + assert.doesNotMatch(result.receipt.text, /apply=applied/u); +}); + function replaceSectionContent(source, heading, content) { const marker = `## ${heading}`; const start = source.indexOf(marker); @@ -62,3 +164,26 @@ function replaceSectionContent(source, heading, content) { const end = next === -1 ? source.length : next; return `${source.slice(0, start + marker.length)}\n\n${content}\n${source.slice(end)}`; } + +function proposalFixture(source, { id, operation, plan }) { + let proposal = source + .replace("status: applied", "status: proposed") + .replace("id: fixture-valid-auto", `id: ${id}`) + .replace("operation: add", `operation: ${operation}`) + .replace( + /^plan_hash:[^\r\n]*$/mu, + `plan_hash: ${computePlanHash(plan)}`, + ); + proposal = replaceSectionContent(proposal, "Decision Log", "None."); + proposal = replaceSectionContent(proposal, "Apply Attempts", "None."); + return replacePatchPlan(proposal, plan); +} + +function replacePatchPlan(source, plan) { + const opening = source.indexOf("~~~~json"); + assert.notEqual(opening, -1); + const jsonStart = source.indexOf("\n", opening) + 1; + const closing = source.indexOf("\n~~~~", jsonStart); + assert.notEqual(closing, -1); + return `${source.slice(0, jsonStart)}${JSON.stringify(plan, null, 2)}${source.slice(closing)}`; +} diff --git a/tests/verification/installer-helpers.mjs b/tests/verification/installer-helpers.mjs index d71f56b..130074f 100644 --- a/tests/verification/installer-helpers.mjs +++ b/tests/verification/installer-helpers.mjs @@ -126,7 +126,7 @@ export function assertFreshInstallerDefaultsToAuto({ runDryRun, runApply }) { assertCommandSucceeded(apply, "fresh installer apply"); const config = readFileSync(join(workspace, ".agent-context", "config.yml"), "utf8"); - assert.match(config, /^created_with_kit_version: "0\.5\.1"$/mu); + assert.match(config, /^created_with_kit_version: "0\.5\.2"$/mu); assert.match(config, /^context_write_policy: auto$/mu); } finally { rmSync(workspace, { recursive: true, force: true }); @@ -218,7 +218,7 @@ export function assertV1ConfigBootstrapContract({ repositoryRoot, runDryRun, run { name: "invalid created kit version", config: templateConfig.replace( - 'created_with_kit_version: "0.5.1"', + 'created_with_kit_version: "0.5.2"', 'created_with_kit_version: "v0.2.0"', ), }, @@ -232,8 +232,8 @@ export function assertV1ConfigBootstrapContract({ repositoryRoot, runDryRun, run { name: "created kit version with decoded trailing newline", config: templateConfig.replace( - 'created_with_kit_version: "0.5.1"', - 'created_with_kit_version: "0.5.1\\n"', + 'created_with_kit_version: "0.5.2"', + 'created_with_kit_version: "0.5.2\\n"', ), }, ]; diff --git a/tests/verification/repository-contract.test.mjs b/tests/verification/repository-contract.test.mjs index 183ee49..b7ce1ef 100644 --- a/tests/verification/repository-contract.test.mjs +++ b/tests/verification/repository-contract.test.mjs @@ -16,7 +16,7 @@ test("package, skill manifest, and context schema versions agree", () => { const manifest = readJson("skills/evolve/manifest.json"); const config = parseYamlSubset(read("templates/.agent-context/config.yml"), "template config"); - assert.equal(packageJson.version, "0.5.1"); + assert.equal(packageJson.version, "0.5.2"); assert.equal(packageJson.engines?.node, ">=20"); assert.equal(manifest.kit, "agent-context-patch"); assert.equal(manifest.version, packageJson.version); From 5593fb47c5674461cb4feb26cc2c38f3fc33347f Mon Sep 17 00:00:00 2001 From: Chengwei Ouyang Date: Sun, 26 Jul 2026 22:39:43 +0800 Subject: [PATCH 2/3] fix: require post-application lifecycle evidence (#12) --- CHANGELOG.md | 2 + ...6-post-application-lifecycle-quiescence.md | 8 +++- docs/v1-verification-matrix.md | 4 +- skills/evolve/references/protocol-v1.md | 8 +++- skills/evolve/runtime/README.md | 12 ++++-- skills/evolve/runtime/lifecycle.mjs | 10 +++-- skills/evolve/runtime/outcome.mjs | 8 ++++ .../lifecycle/lifecycle-coordinator.test.mjs | 28 +++++------- tests/lifecycle/proposal-fixture-helpers.mjs | 19 ++++++++ tests/outcome/evolution-outcome.test.mjs | 43 +++++++++++++++++++ .../lifecycle-outcome.integration.test.mjs | 24 +++-------- 11 files changed, 117 insertions(+), 49 deletions(-) create mode 100644 tests/lifecycle/proposal-fixture-helpers.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a5b043..a7ce41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ semantic versions for the Kit independently from the Workspace Schema version. unrelated approval-waiting proposals remain non-blocking. - The real Coordinator-to-Outcome path now verifies that a post-application sibling blocker cannot produce an applied-success receipt. +- Applied Coordinator results now carry explicit post-application verification, + which the Outcome Interface requires before it can publish success. ## [0.5.1] - 2026-07-19 diff --git a/docs/adr/0006-post-application-lifecycle-quiescence.md b/docs/adr/0006-post-application-lifecycle-quiescence.md index b87992d..3bed6b8 100644 --- a/docs/adr/0006-post-application-lifecycle-quiescence.md +++ b/docs/adr/0006-post-application-lifecycle-quiescence.md @@ -31,7 +31,9 @@ reconcileWorkspaceProposalLifecycles({ workspaceRoot }) No pass counter, dependency graph, semantic ordering, or new public action is added. Callers continue to receive one content-safe outcome per inspected -proposal and `inspectedCount === outcomes.length`. +proposal and `inspectedCount === outcomes.length`. When the call applied work +and then completed a no-new-application observation pass, it also returns +`postApplicationVerified: true`. ### 2. Reconcile a stable proposal cohort to post-application quiescence @@ -75,7 +77,9 @@ remain Agent responsibilities. proposal sorts first. - Unrelated approval-waiting proposals remain non-blocking. - The Outcome Interface receives complete final sibling evidence and therefore - cannot publish a false applied-success receipt. + cannot publish a false applied-success receipt. It requires + `postApplicationVerified: true` before accepting any applied transition, so a + structurally plausible pre-fix result fails closed. - Runtime work can include more than one proposal scan, but no new filesystem or semantic surface is exposed to callers. diff --git a/docs/v1-verification-matrix.md b/docs/v1-verification-matrix.md index 6ffa551..5a580c0 100644 --- a/docs/v1-verification-matrix.md +++ b/docs/v1-verification-matrix.md @@ -15,8 +15,8 @@ future changes do not turn documentation claims into untested promises. | Scope | Workspace is the only active write scope; user-global is a sanitized, approved handoff. | proposal fixtures and validator; `references/protocol-v1.md` | | Write policy | Only `propose` and `auto`; new workspaces default to auto, existing config is preserved, and every automatic write still requires one complete live v1 config plus all target, domain, risk, health, and privacy gates. | default-policy contract; shared production config validator; installer preservation tests; kernel live-config and auto-gate tests | | Approval lifecycle | Eligible auto plans complete in the current Agent turn with a `policy_auto` Decision and one non-blocking receipt. `$evolve approve` handles exceptions and binds a complete persisted PatchPlan, including its semantic operation, to the exact external `planHash`. | auto-default Kernel outcome; applied `policy_auto` demo aggregate; fresh-Agent acceptance record; recomputed proposal/Decision/Attempt and exact-approval tests | -| Lifecycle reconciliation | Unfinished exact auto or approved plans resume only from all-before state; all-after without an applied audit, mixed state, and semantic target drift fail closed. After an exact application, a bounded follow-up pass makes the result reflect the coordinator's own final target and policy state independent of filename order. Current unrelated approval-only proposals remain non-blocking. A never-applied stale approval terminates only after a recognized conflict and a valid named replacement exists. | production proposal validator and shared auto-eligibility predicate; lifecycle coordinator fixed-point, proposal-store retry, approval-waiting tighten, and target-inspection behavior tests; real Outcome integration; lock, idempotency, stale replacement, and content-safe result assertions | -| Observable delivery | After a verified high-signal repair, the Agent supplies semantic detect/propose results and the Outcome Interface maps exact Coordinator evidence to apply. Only legal state families produce one content-safe three-stage receipt; no-trigger tasks stay silent and create no no-op proposal. | Outcome unit and real-Coordinator integration tests; shared Codex/Claude adapter contract; fresh-Agent positive and negative acceptance record | +| Lifecycle reconciliation | Unfinished exact auto or approved plans resume only from all-before state; all-after without an applied audit, mixed state, and semantic target drift fail closed. After an exact application, a bounded follow-up pass makes the result reflect the coordinator's own final target and policy state independent of filename order, then marks that post-application observation explicitly. Current unrelated approval-only proposals remain non-blocking. A never-applied stale approval terminates only after a recognized conflict and a valid named replacement exists. | production proposal validator and shared auto-eligibility predicate; lifecycle coordinator fixed-point, proposal-store retry, approval-waiting tighten, and target-inspection behavior tests; real Outcome integration; lock, idempotency, stale replacement, and content-safe result assertions | +| Observable delivery | After a verified high-signal repair, the Agent supplies semantic detect/propose results and the Outcome Interface maps exact Coordinator evidence to apply. Applied success also requires explicit post-application verification, so pre-fixed-point evidence fails closed. Only legal state families produce one content-safe three-stage receipt; no-trigger tasks stay silent and create no no-op proposal. | Outcome unit and real-Coordinator integration tests; shared Codex/Claude adapter contract; fresh-Agent positive and negative acceptance record | | Installation | Agent resolves semantics; Bootstrap plans deterministic files, validates the complete config envelope without Node, and never edits instructions. | shared PowerShell/Bash invalid/valid config, dry-run/apply/idempotency, and guidance-preservation tests | | Runtime capability | Bootstrap and propose do not require Node; the default auto path uses the Node kernel or explicitly downgrades with one blocking reason. | native installer adapters; skill and adapter auto-default contract; kernel tests | | Migration | Legacy context is read-only until a reviewed migration creates byte-identical backups and applies exact v1 updates; future schemas remain read-only. | legacy, invalid, missing-config, and future-schema tests; approved backup-and-migrate plus missing-backup kernel tests | diff --git a/skills/evolve/references/protocol-v1.md b/skills/evolve/references/protocol-v1.md index c9a8508..2a461b1 100644 --- a/skills/evolve/references/protocol-v1.md +++ b/skills/evolve/references/protocol-v1.md @@ -145,7 +145,9 @@ proposal cohort until one pass performs no new successful application. Each proposal contributes only its latest outcome, while an applied transition is retained after that proposal becomes terminal. The returned status therefore describes state after the coordinator's own writes rather than the order in -which proposal filenames were inspected. +which proposal filenames were inspected. If the call applied work, it returns +`postApplicationVerified: true` only after the final pass observes no new +successful application. A result may be `settled` while listing `approval_required`: ordinary current approval-only proposals are intentionally waiting for review and do not block @@ -333,7 +335,9 @@ Every other combination fails closed as `invalid_evolution_outcome`. An `applied` outcome additionally requires a valid proposal ID, settled reconciliation, one exact Coordinator outcome, a non-terminal-to-applied exact resume action, reason `applied`, consistent Coordinator accounting, and at -least one safe relative target. Every inspected Coordinator outcome must also +least one safe relative target. Reconciliation containing an applied transition +must also prove it completed its post-application observation pass with +`postApplicationVerified: true`. Every inspected Coordinator outcome must also have its complete content-safe shape and a valid action/status relationship. Matching target bytes, a terminal-to-terminal pseudo transition, missing audit evidence, or one applied proposal inside an otherwise blocked workspace cannot diff --git a/skills/evolve/runtime/README.md b/skills/evolve/runtime/README.md index fd06eff..075cba7 100644 --- a/skills/evolve/runtime/README.md +++ b/skills/evolve/runtime/README.md @@ -100,6 +100,8 @@ until a pass performs no new successful application. The final result therefore reflects target and policy state after the coordinator's own writes, independent of proposal filename order. It retains one latest outcome per inspected proposal, including the applied transition for a proposal that became terminal. +Such a result includes `postApplicationVerified: true` only after a successful +application is followed by a pass with no new successful application. `status: settled` means no unsafe mechanical lifecycle gap remains. It can still contain `approval_required` outcomes; those proposals are intentionally waiting @@ -147,10 +149,12 @@ optional content-safe `proposalId`, optional sorted workspace-relative `targets`, and one fixed-format `receipt`. It rejects invalid state families and cannot report `applied` unless settled Coordinator evidence proves one exact non-terminal-to-applied resume with an applied audit and at least one safe -target. Every inspected outcome must have the complete content-safe Coordinator -shape and a valid action/status relationship. Missing, malformed, ambiguous, -blocked, or partially consistent evidence becomes a content-safe blocker -instead of a success claim. +target. Applied evidence must also carry `postApplicationVerified: true`; this +prevents a pre-fixed-point result from authorizing success. Every inspected +outcome must have the complete content-safe Coordinator shape and a valid +action/status relationship. Missing, malformed, ambiguous, blocked, or +partially consistent evidence becomes a content-safe blocker instead of a +success claim. The module copies no proposal prose, PatchPlan content, target content, conversation data, or absolute path. Unsafe lifecycle targets are removed. It diff --git a/skills/evolve/runtime/lifecycle.mjs b/skills/evolve/runtime/lifecycle.mjs index 9482b5a..f3d6c73 100644 --- a/skills/evolve/runtime/lifecycle.mjs +++ b/skills/evolve/runtime/lifecycle.mjs @@ -72,6 +72,7 @@ async function reconcileWhileLocked({ workspaceRoot, proposalsRoot, missing }) { isProposalCandidate(entry.name), ).length; const outcomesByEntry = new Map(); + let applicationSeen = false; for (let pass = 0; pass <= candidateCount; pass += 1) { const passOutcomes = await reconcilePass({ @@ -82,9 +83,10 @@ async function reconcileWhileLocked({ workspaceRoot, proposalsRoot, missing }) { for (const { entryName, outcome } of passOutcomes) { outcomesByEntry.set(entryName, outcome); } - if ( - !passOutcomes.some(({ outcome }) => isAppliedLifecycleOutcome(outcome)) - ) { + const appliedThisPass = passOutcomes.some(({ outcome }) => + isAppliedLifecycleOutcome(outcome), + ); + if (!appliedThisPass) { const outcomes = [...outcomesByEntry.values()]; const status = deriveLifecycleReconciliationStatus(outcomes); if (status === undefined) throw new TypeError("invalid_lifecycle_outcome"); @@ -92,8 +94,10 @@ async function reconcileWhileLocked({ workspaceRoot, proposalsRoot, missing }) { status, inspectedCount: outcomes.length, outcomes, + ...(applicationSeen ? { postApplicationVerified: true } : {}), }; } + applicationSeen = true; } throw new TypeError("lifecycle_reconciliation_not_quiescent"); diff --git a/skills/evolve/runtime/outcome.mjs b/skills/evolve/runtime/outcome.mjs index 76ed1c6..e7a3d35 100644 --- a/skills/evolve/runtime/outcome.mjs +++ b/skills/evolve/runtime/outcome.mjs @@ -220,6 +220,14 @@ function inspectLifecycleEvidence(reconciliation, proposalId) { ) { return { problem: "invalid_lifecycle_evidence" }; } + if ( + reconciliation.outcomes.some((outcome) => + isAppliedLifecycleOutcome(outcome), + ) && + reconciliation.postApplicationVerified !== true + ) { + return { problem: "invalid_lifecycle_evidence" }; + } const matches = inspectedOutcomes.filter( ({ outcome }) => outcome.proposalId === proposalId, ); diff --git a/tests/lifecycle/lifecycle-coordinator.test.mjs b/tests/lifecycle/lifecycle-coordinator.test.mjs index 01ba866..112c895 100644 --- a/tests/lifecycle/lifecycle-coordinator.test.mjs +++ b/tests/lifecycle/lifecycle-coordinator.test.mjs @@ -10,6 +10,10 @@ import { inspectProposalDocument, validateProposalDocument, } from "../../skills/evolve/runtime/proposal.mjs"; +import { + replacePatchPlan, + replaceSectionContent, +} from "./proposal-fixture-helpers.mjs"; const fixtureRoot = join(import.meta.dirname, "..", "verification", "fixtures"); @@ -35,6 +39,7 @@ test("reconciliation resumes an exact interrupted auto proposal and completes it assert.deepEqual(result, { status: "settled", inspectedCount: 1, + postApplicationVerified: true, outcomes: [ { proposalId: "fixture-valid-auto", @@ -94,6 +99,7 @@ test("reconciliation preserves exact human approval while every beforeHash still assert.deepEqual(result, { status: "settled", inspectedCount: 1, + postApplicationVerified: true, outcomes: [ { proposalId: "fixture-valid-auto", @@ -215,6 +221,7 @@ test("reconciliation reports a sibling made stale by an auto apply in the same c assert.equal(result.status, "blocked"); assert.equal(result.inspectedCount, 2); + assert.equal(result.postApplicationVerified, true); assert.deepEqual(result.outcomes, [ { proposalId: "fixture-approval-waiting", @@ -269,6 +276,7 @@ test("post-application reconciliation is independent of proposal filename order" assert.equal(result.status, "blocked"); assert.equal(result.inspectedCount, 2); + assert.equal(result.postApplicationVerified, true); assert.equal(outcomesById.get("fixture-auto-update")?.reason, "applied"); assert.equal( outcomesById.get("fixture-approval-waiting")?.reason, @@ -317,6 +325,7 @@ test("post-application reconciliation leaves unrelated approval work actionable" assert.equal(result.status, "settled"); assert.equal(result.inspectedCount, 2); + assert.equal(result.postApplicationVerified, true); assert.equal( outcomesById.get("fixture-approval-waiting")?.action, "approval_required", @@ -572,6 +581,7 @@ test("a blocked auto attempt is audited and the exact decision can resume later" ); const resumed = await reconcileWorkspaceProposalLifecycles({ workspaceRoot }); + assert.equal(resumed.postApplicationVerified, true); assert.deepEqual(resumed.outcomes, [ { proposalId: "fixture-valid-auto", @@ -605,15 +615,6 @@ async function createWorkspace(t, { policy }) { return workspaceRoot; } -function replaceSectionContent(source, heading, content) { - const headingMarker = `## ${heading}`; - const start = source.indexOf(headingMarker); - assert.notEqual(start, -1, `fixture is missing ${headingMarker}`); - const next = source.indexOf("\n## ", start + headingMarker.length); - const end = next === -1 ? source.length : next; - return `${source.slice(0, start + headingMarker.length)}\n\n${content}\n${source.slice(end)}`; -} - async function interruptedAutoFixture() { const source = await readFile( join(fixtureRoot, "proposals", "valid-auto.md"), @@ -716,12 +717,3 @@ async function interruptedAutoUpdateFixture({ plan, ); } - -function replacePatchPlan(source, plan) { - const opening = source.indexOf("~~~~json"); - assert.notEqual(opening, -1); - const jsonStart = source.indexOf("\n", opening) + 1; - const closing = source.indexOf("\n~~~~", jsonStart); - assert.notEqual(closing, -1); - return `${source.slice(0, jsonStart)}${JSON.stringify(plan, null, 2)}${source.slice(closing)}`; -} diff --git a/tests/lifecycle/proposal-fixture-helpers.mjs b/tests/lifecycle/proposal-fixture-helpers.mjs new file mode 100644 index 0000000..c0ac4e4 --- /dev/null +++ b/tests/lifecycle/proposal-fixture-helpers.mjs @@ -0,0 +1,19 @@ +import assert from "node:assert/strict"; + +export function replaceSectionContent(source, heading, content) { + const marker = `## ${heading}`; + const start = source.indexOf(marker); + assert.notEqual(start, -1, `fixture is missing ${marker}`); + const next = source.indexOf("\n## ", start + marker.length); + const end = next === -1 ? source.length : next; + return `${source.slice(0, start + marker.length)}\n\n${content}\n${source.slice(end)}`; +} + +export function replacePatchPlan(source, plan) { + const opening = source.indexOf("~~~~json"); + assert.notEqual(opening, -1); + const jsonStart = source.indexOf("\n", opening) + 1; + const closing = source.indexOf("\n~~~~", jsonStart); + assert.notEqual(closing, -1); + return `${source.slice(0, jsonStart)}${JSON.stringify(plan, null, 2)}${source.slice(closing)}`; +} diff --git a/tests/outcome/evolution-outcome.test.mjs b/tests/outcome/evolution-outcome.test.mjs index 5d16d17..8596616 100644 --- a/tests/outcome/evolution-outcome.test.mjs +++ b/tests/outcome/evolution-outcome.test.mjs @@ -35,6 +35,7 @@ test("an applied outcome requires the exact proposal lifecycle audit", () => { reconciliation: { status: "settled", inspectedCount: 1, + postApplicationVerified: true, outcomes: [ { proposalId, @@ -65,6 +66,44 @@ test("an applied outcome requires the exact proposal lifecycle audit", () => { }); }); +test("an applied outcome requires explicit post-application verification", () => { + const proposalId = "2026-07-26-unverified-fixed-point"; + const target = ".agent-context/PROJECT_PROFILE.md"; + const result = finalizeEvolutionOutcome({ + detect: { status: "candidate", reason: "stale_context" }, + propose: { status: "created", reason: "proposal_created" }, + proposalId, + reconciliation: { + status: "settled", + inspectedCount: 2, + outcomes: [ + { + proposalId: "approval-sibling", + beforeStatus: "proposed", + afterStatus: "proposed", + action: "approval_required", + reason: "policy_requires_approval", + targets: [target], + }, + { + proposalId, + beforeStatus: "proposed", + afterStatus: "applied", + action: "resume_exact_auto", + reason: "applied", + targets: [target], + }, + ], + }, + }); + + assert.deepEqual(result.apply, { + status: "blocked", + reason: "invalid_lifecycle_evidence", + }); + assert.equal(result.receipt.kind, "blocked"); +}); + test("an approval-only proposal is reported as a concise nonblocking exception", () => { const proposalId = "2026-07-19-semantic-tighten"; const target = ".agent-context/PROJECT_PROFILE.md"; @@ -178,6 +217,7 @@ test("an existing proposal can reconcile without pretending to create it again", reconciliation: { status: "settled", inspectedCount: 1, + postApplicationVerified: true, outcomes: [ { proposalId, @@ -257,6 +297,7 @@ test("a blocked workspace reconciliation cannot hide behind one applied proposal reconciliation: { status: "blocked", inspectedCount: 2, + postApplicationVerified: true, outcomes: [ { proposalId, @@ -420,6 +461,7 @@ test("observable targets are normalized and lifecycle prose is ignored", () => { reconciliation: { status: "settled", inspectedCount: 1, + postApplicationVerified: true, outcomes: [ { proposalId, @@ -453,6 +495,7 @@ test("applied requires at least one audited workspace target", () => { reconciliation: { status: "settled", inspectedCount: 1, + postApplicationVerified: true, outcomes: [ { proposalId, diff --git a/tests/outcome/lifecycle-outcome.integration.test.mjs b/tests/outcome/lifecycle-outcome.integration.test.mjs index 2c71a4d..019dfc7 100644 --- a/tests/outcome/lifecycle-outcome.integration.test.mjs +++ b/tests/outcome/lifecycle-outcome.integration.test.mjs @@ -11,6 +11,10 @@ import { import { reconcileWorkspaceProposalLifecycles } from "../../skills/evolve/runtime/lifecycle.mjs"; import { finalizeEvolutionOutcome } from "../../skills/evolve/runtime/outcome.mjs"; import { inspectProposalDocument } from "../../skills/evolve/runtime/proposal.mjs"; +import { + replacePatchPlan, + replaceSectionContent, +} from "../lifecycle/proposal-fixture-helpers.mjs"; const fixtureRoot = join(import.meta.dirname, "..", "verification", "fixtures"); @@ -52,6 +56,7 @@ test("the delivery checkpoint reports applied only after the real coordinator re reconciliation, }); + assert.equal(reconciliation.postApplicationVerified, true); assert.deepEqual(result.apply, { status: "applied", reason: "applied" }); assert.deepEqual(result.targets, [".agent-context/PROJECT_PROFILE.md"]); assert.equal(result.receipt.kind, "applied"); @@ -148,6 +153,7 @@ test("the delivery checkpoint blocks success when the same apply makes a sibling }); assert.equal(reconciliation.status, "blocked"); + assert.equal(reconciliation.postApplicationVerified, true); assert.deepEqual(result.apply, { status: "blocked", reason: "workspace_reconciliation_blocked", @@ -156,15 +162,6 @@ test("the delivery checkpoint blocks success when the same apply makes a sibling assert.doesNotMatch(result.receipt.text, /apply=applied/u); }); -function replaceSectionContent(source, heading, content) { - const marker = `## ${heading}`; - const start = source.indexOf(marker); - assert.notEqual(start, -1, `fixture is missing ${marker}`); - const next = source.indexOf("\n## ", start + marker.length); - const end = next === -1 ? source.length : next; - return `${source.slice(0, start + marker.length)}\n\n${content}\n${source.slice(end)}`; -} - function proposalFixture(source, { id, operation, plan }) { let proposal = source .replace("status: applied", "status: proposed") @@ -178,12 +175,3 @@ function proposalFixture(source, { id, operation, plan }) { proposal = replaceSectionContent(proposal, "Apply Attempts", "None."); return replacePatchPlan(proposal, plan); } - -function replacePatchPlan(source, plan) { - const opening = source.indexOf("~~~~json"); - assert.notEqual(opening, -1); - const jsonStart = source.indexOf("\n", opening) + 1; - const closing = source.indexOf("\n~~~~", jsonStart); - assert.notEqual(closing, -1); - return `${source.slice(0, jsonStart)}${JSON.stringify(plan, null, 2)}${source.slice(closing)}`; -} From 6f8836c8db8cdf8289b4dec6de6d49d39dc1126c Mon Sep 17 00:00:00 2001 From: Chengwei Ouyang Date: Sun, 26 Jul 2026 22:41:57 +0800 Subject: [PATCH 3/3] test: centralize proposal fixture helpers --- tests/lifecycle/lifecycle-coordinator.test.mjs | 2 +- tests/outcome/lifecycle-outcome.integration.test.mjs | 2 +- .../proposal-fixture-helpers.mjs | 0 tests/verification/proposal-fixtures.test.mjs | 10 +--------- 4 files changed, 3 insertions(+), 11 deletions(-) rename tests/{lifecycle => support}/proposal-fixture-helpers.mjs (100%) diff --git a/tests/lifecycle/lifecycle-coordinator.test.mjs b/tests/lifecycle/lifecycle-coordinator.test.mjs index 112c895..d37d6b7 100644 --- a/tests/lifecycle/lifecycle-coordinator.test.mjs +++ b/tests/lifecycle/lifecycle-coordinator.test.mjs @@ -13,7 +13,7 @@ import { import { replacePatchPlan, replaceSectionContent, -} from "./proposal-fixture-helpers.mjs"; +} from "../support/proposal-fixture-helpers.mjs"; const fixtureRoot = join(import.meta.dirname, "..", "verification", "fixtures"); diff --git a/tests/outcome/lifecycle-outcome.integration.test.mjs b/tests/outcome/lifecycle-outcome.integration.test.mjs index 019dfc7..a452701 100644 --- a/tests/outcome/lifecycle-outcome.integration.test.mjs +++ b/tests/outcome/lifecycle-outcome.integration.test.mjs @@ -14,7 +14,7 @@ import { inspectProposalDocument } from "../../skills/evolve/runtime/proposal.mj import { replacePatchPlan, replaceSectionContent, -} from "../lifecycle/proposal-fixture-helpers.mjs"; +} from "../support/proposal-fixture-helpers.mjs"; const fixtureRoot = join(import.meta.dirname, "..", "verification", "fixtures"); diff --git a/tests/lifecycle/proposal-fixture-helpers.mjs b/tests/support/proposal-fixture-helpers.mjs similarity index 100% rename from tests/lifecycle/proposal-fixture-helpers.mjs rename to tests/support/proposal-fixture-helpers.mjs diff --git a/tests/verification/proposal-fixtures.test.mjs b/tests/verification/proposal-fixtures.test.mjs index 4acb3df..6baabfc 100644 --- a/tests/verification/proposal-fixtures.test.mjs +++ b/tests/verification/proposal-fixtures.test.mjs @@ -4,6 +4,7 @@ import { join } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { replaceSectionContent } from "../support/proposal-fixture-helpers.mjs"; import { validateProposalDocument } from "./proposal-contract.mjs"; const fixtureRoot = fileURLToPath(new URL("fixtures/proposals", import.meta.url)); @@ -482,15 +483,6 @@ function replaceInSection(source, heading, search, replacement) { return `${source.slice(0, start)}${section.replace(search, replacement)}${source.slice(end)}`; } -function replaceSectionContent(source, heading, content) { - const headingMarker = `## ${heading}`; - const start = source.indexOf(headingMarker); - assert.notEqual(start, -1, `fixture is missing ${headingMarker}`); - const next = source.indexOf("\n## ", start + headingMarker.length); - const end = next === -1 ? source.length : next; - return `${source.slice(0, start + headingMarker.length)}\n\n${content}\n${source.slice(end)}`; -} - function asPending(source) { let pending = source .replace("status: applied", "status: pending_current_fix")