Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
96 changes: 96 additions & 0 deletions docs/adr/0006-post-application-lifecycle-quiescence.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/v1-verification-matrix.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion skills/evolve/manifest.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"kit": "agent-context-patch",
"version": "0.5.1",
"version": "0.5.2",
"schemaVersion": 1
}
2 changes: 1 addition & 1 deletion skills/evolve/references/config-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion skills/evolve/references/protocol-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
18 changes: 14 additions & 4 deletions skills/evolve/runtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
85 changes: 65 additions & 20 deletions skills/evolve/runtime/lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { applyPatchPlan, sha256Text } from "./index.mjs";
import { inspectPatchPlanTargets } from "./internal.mjs";
import {
deriveLifecycleReconciliationStatus,
isAppliedLifecycleOutcome,
isLifecycleIdentifier,
isTerminalProposalStatus,
} from "./lifecycle-contract.mjs";
Expand Down Expand Up @@ -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 = {
Expand All @@ -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({
Expand Down
8 changes: 8 additions & 0 deletions skills/evolve/runtime/outcome.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
2 changes: 1 addition & 1 deletion templates/.agent-context/config.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading