diff --git a/CHANGELOG.md b/CHANGELOG.md index 3613544..a7ce41d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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. +- 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 ### 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..3bed6b8 --- /dev/null +++ b/docs/adr/0006-post-application-lifecycle-quiescence.md @@ -0,0 +1,96 @@ +# 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`. 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 + +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. 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. + +## 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..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. 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 | -| 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/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..2a461b1 100644 --- a/skills/evolve/references/protocol-v1.md +++ b/skills/evolve/references/protocol-v1.md @@ -140,6 +140,15 @@ 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. 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 unrelated evolve workflows. Unsafe auto, stale, mixed, malformed, or audit-gap @@ -326,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 6b733ff..075cba7 100644 --- a/skills/evolve/runtime/README.md +++ b/skills/evolve/runtime/README.md @@ -95,6 +95,14 @@ 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. +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 for informed human review and do not block unrelated reconciliation, weekly @@ -141,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 81201eb..f3d6c73 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,75 @@ 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(); + let applicationSeen = false; + + 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); + } + 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"); + return { + status, + inspectedCount: outcomes.length, + outcomes, + ...(applicationSeen ? { postApplicationVerified: true } : {}), + }; + } + applicationSeen = true; + } + + 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 +155,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/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/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..d37d6b7 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 "../support/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", @@ -184,6 +190,149 @@ 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.equal(result.postApplicationVerified, true); + 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(result.postApplicationVerified, true); + 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(result.postApplicationVerified, true); + 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( @@ -432,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", @@ -465,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"), @@ -545,11 +686,34 @@ async function approvalWaitingTightenFixture({ before }) { ); } -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)}`; +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, + ); } 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 6aaf474..a452701 100644 --- a/tests/outcome/lifecycle-outcome.integration.test.mjs +++ b/tests/outcome/lifecycle-outcome.integration.test.mjs @@ -4,8 +4,17 @@ 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"; +import { + replacePatchPlan, + replaceSectionContent, +} from "../support/proposal-fixture-helpers.mjs"; const fixtureRoot = join(import.meta.dirname, "..", "verification", "fixtures"); @@ -47,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"); @@ -54,11 +64,114 @@ test("the delivery checkpoint reports applied only after the real coordinator re assert.match(await readFile(proposalPath, "utf8"), /result: 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)}`; +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.equal(reconciliation.postApplicationVerified, true); + 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 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); } diff --git a/tests/support/proposal-fixture-helpers.mjs b/tests/support/proposal-fixture-helpers.mjs new file mode 100644 index 0000000..c0ac4e4 --- /dev/null +++ b/tests/support/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/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/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") 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);