Context
Follow-up to #6060's decided ADR (see that issue's closing comment for the full decision). Blocked by the config-schema issue that adds AmsPolicySpec.selfLoopAutonomy -- this issue reads that field. This issue wires it into decideNextAction's actual decision logic and threads it end-to-end from the operator's parsed AmsPolicySpec through to IterationState.
decideNextActionWithReason (packages/loopover-engine/src/miner/iterate-policy.ts:143-179) currently has NO notion of autonomy -- its header explicitly calls this out as a deferred gap (iterate-policy.ts:18-21: "this module's decideNextAction is autonomy-level-agnostic today"). The precedence order (iterate-policy.ts:132-141) is: (1) rejectionSignaled always wins (iterate-policy.ts:10-13, "disengage SILENTLY on rejection" -- must stay absolute), (2) selfReview.kind === "ambiguous" always abandons, (3) selfReview.kind === "pass" is currently the ONLY path to "handoff" (iterate-policy.ts:154-156), (4)-(6) further abandon/continue checks. The new autonomy check narrows step 3 ONLY -- steps 1 and 2 are untouched.
The end-to-end plumbing precedent for a new field reaching IterationState already exists for rejectionSignaled: it's declared on IterateLoopInput (packages/loopover-engine/src/miner/iterate-loop.ts:85-88), resolved by the caller, and copied verbatim into the per-iteration IterationState at the loop's state-construction site (iterate-loop.ts:449-455). The actual "caller" that assembles IterateLoopInput from a real AmsPolicySpec is buildAttemptLoopInput in packages/loopover-miner/lib/attempt-input-builder.js:73-103 -- it already maps input.amsPolicySpec.maxIterations/maxTurnsPerIteration (attempt-input-builder.js:80-81) and passes input.rejectionSignaled straight through (attempt-input-builder.js:101); the new field follows the exact same two mappings.
Requirements
- Add
autonomyLevel?: AutonomyLevel | undefined to IterationState (iterate-policy.ts:65-88), importing AutonomyLevel the same way settings/autonomy.ts:1 does (from ../types/manifest-deps-types.js, since iterate-policy.ts lives one level under src/ same as settings/autonomy.ts). Optional and defaults to treated-as-"auto" when undefined -- same precedent as costCeilingReached (iterate-policy.ts:71-77, with its own dedicated omitted/false tests at packages/loopover-engine/test/iterate-policy.test.ts:114-131) -- so every pre-existing hand-built IterationState fixture across the test suite remains valid with unchanged output.
- Add
requiresApproval?: true | undefined to IterateLoopDecision (iterate-policy.ts:111-118), mirroring autonomyRequiresApproval (settings/autonomy.ts:46-48). Additive-only field; do not touch any existing .action-only assertion.
- Add
"autonomy_observe_only" to the AbandonReason union (iterate-policy.ts:30-38).
- In
decideNextActionWithReason, narrow the step-3 selfReview.kind === "pass" branch ONLY (iterate-policy.ts:154-156) into exactly this behavior, resolving the effective level as state.autonomyLevel ?? "auto":
"auto" (or unset): unchanged -- { action: "handoff", reason: "..." }, no requiresApproval.
"auto_with_approval": still { action: "handoff", ... }, PLUS requiresApproval: true.
"observe": { action: "abandon", abandonReason: "autonomy_observe_only", reason: "..." } -- the reason string must note a clean predicted-gate pass WAS reached but the configured level keeps the loop observe-only (do not reuse self_review_ambiguous's or any other existing reason's wording).
- Do not alter steps 1, 2, 4, 5, or 6 of the precedence ladder in any way.
- Add
autonomyLevel?: AutonomyLevel | undefined to IterateLoopInput (iterate-loop.ts:43-89), same optional/doc-comment shape as rejectionSignaled (iterate-loop.ts:85-88), and copy it into the IterationState construction at iterate-loop.ts:449-455.
- In
buildAttemptLoopInput (packages/loopover-miner/lib/attempt-input-builder.js:73-103), map autonomyLevel: input.amsPolicySpec.selfLoopAutonomy into the returned IterateLoopInput, following the exact same pass-through pattern as rejectionSignaled at attempt-input-builder.js:101.
maxIterations/capLimits remain the only iteration/budget controls -- this field must gate ONLY the pass->handoff transition, nothing else (no change to iteration-count or write-action-scope logic anywhere in this diff).
Deliverables
Test Coverage Requirements
Read this before starting. Same Codecov caveat as the config-schema issue: packages/loopover-engine/src/** and packages/loopover-miner/lib/** are BOTH in vitest.config.ts's coverage.include, and BOTH are measured for Codecov's 99% patch gate ONLY through root test/**/*.test.ts (vitest) -- never through packages/loopover-engine's own node --test suite. Update BOTH the root vitest tests (required for Codecov) and packages/loopover-engine/test/iterate-policy.test.ts (required to keep npm run test --workspace @loopover/engine green in test:ci).
- All three
autonomyLevel values tested against a selfReview.kind === "pass" state: "auto" -> handoff/no requiresApproval; "auto_with_approval" -> handoff/requiresApproval: true; "observe" -> abandon/abandonReason: "autonomy_observe_only".
- An explicit byte-identical regression test: construct the exact same passing
IterationState twice, once with autonomyLevel: undefined (or the field omitted entirely) and once with autonomyLevel: "auto" explicitly, and assert decideNextActionWithReason returns deep-equal decisions for both -- proving the new field is a true no-op when unset, matching costCeilingReached's own omitted/explicit-default test pair (packages/loopover-engine/test/iterate-policy.test.ts:114-131).
- A test proving
rejectionSignaled and selfReview.kind === "ambiguous" still win over ANY autonomyLevel value, including "observe" (i.e. these steps are unreachable-by-autonomy, not merely untested by omission).
buildAttemptLoopInput's new mapping needs its own assertion in test/unit/miner-attempt-input-builder.test.ts (existing describe("buildAttemptLoopInput (#5132)", ...) block at line 112, following the same pattern as its existing "threads a real rejectionSignaled:true through unchanged" test at line 166) proving amsPolicySpec.selfLoopAutonomy flows unchanged into the built IterateLoopInput.autonomyLevel.
Expected Outcome
An operator's .loopover-ams.yml selfLoopAutonomy setting now actually gates whether a clean self-review pass hands off unconditionally, hands off pending approval, or stays observe-only-and-abandons -- with a repo that leaves the field unset behaving byte-identically to pre-this-issue code.
Links & Resources
Context
Follow-up to
#6060's decided ADR (see that issue's closing comment for the full decision). Blocked by the config-schema issue that addsAmsPolicySpec.selfLoopAutonomy-- this issue reads that field. This issue wires it intodecideNextAction's actual decision logic and threads it end-to-end from the operator's parsedAmsPolicySpecthrough toIterationState.decideNextActionWithReason(packages/loopover-engine/src/miner/iterate-policy.ts:143-179) currently has NO notion of autonomy -- its header explicitly calls this out as a deferred gap (iterate-policy.ts:18-21: "this module'sdecideNextActionis autonomy-level-agnostic today"). The precedence order (iterate-policy.ts:132-141) is: (1)rejectionSignaledalways wins (iterate-policy.ts:10-13, "disengage SILENTLY on rejection" -- must stay absolute), (2)selfReview.kind === "ambiguous"always abandons, (3)selfReview.kind === "pass"is currently the ONLY path to"handoff"(iterate-policy.ts:154-156), (4)-(6) further abandon/continue checks. The new autonomy check narrows step 3 ONLY -- steps 1 and 2 are untouched.The end-to-end plumbing precedent for a new field reaching
IterationStatealready exists forrejectionSignaled: it's declared onIterateLoopInput(packages/loopover-engine/src/miner/iterate-loop.ts:85-88), resolved by the caller, and copied verbatim into the per-iterationIterationStateat the loop's state-construction site (iterate-loop.ts:449-455). The actual "caller" that assemblesIterateLoopInputfrom a realAmsPolicySpecisbuildAttemptLoopInputinpackages/loopover-miner/lib/attempt-input-builder.js:73-103-- it already mapsinput.amsPolicySpec.maxIterations/maxTurnsPerIteration(attempt-input-builder.js:80-81) and passesinput.rejectionSignaledstraight through (attempt-input-builder.js:101); the new field follows the exact same two mappings.Requirements
autonomyLevel?: AutonomyLevel | undefinedtoIterationState(iterate-policy.ts:65-88), importingAutonomyLevelthe same waysettings/autonomy.ts:1does (from../types/manifest-deps-types.js, sinceiterate-policy.tslives one level undersrc/same assettings/autonomy.ts). Optional and defaults to treated-as-"auto"whenundefined-- same precedent ascostCeilingReached(iterate-policy.ts:71-77, with its own dedicated omitted/false tests atpackages/loopover-engine/test/iterate-policy.test.ts:114-131) -- so every pre-existing hand-builtIterationStatefixture across the test suite remains valid with unchanged output.requiresApproval?: true | undefinedtoIterateLoopDecision(iterate-policy.ts:111-118), mirroringautonomyRequiresApproval(settings/autonomy.ts:46-48). Additive-only field; do not touch any existing.action-only assertion."autonomy_observe_only"to theAbandonReasonunion (iterate-policy.ts:30-38).decideNextActionWithReason, narrow the step-3selfReview.kind === "pass"branch ONLY (iterate-policy.ts:154-156) into exactly this behavior, resolving the effective level asstate.autonomyLevel ?? "auto":"auto"(or unset): unchanged --{ action: "handoff", reason: "..." }, norequiresApproval."auto_with_approval": still{ action: "handoff", ... }, PLUSrequiresApproval: true."observe":{ action: "abandon", abandonReason: "autonomy_observe_only", reason: "..." }-- the reason string must note a clean predicted-gate pass WAS reached but the configured level keeps the loop observe-only (do not reuseself_review_ambiguous's or any other existing reason's wording).autonomyLevel?: AutonomyLevel | undefinedtoIterateLoopInput(iterate-loop.ts:43-89), same optional/doc-comment shape asrejectionSignaled(iterate-loop.ts:85-88), and copy it into theIterationStateconstruction atiterate-loop.ts:449-455.buildAttemptLoopInput(packages/loopover-miner/lib/attempt-input-builder.js:73-103), mapautonomyLevel: input.amsPolicySpec.selfLoopAutonomyinto the returnedIterateLoopInput, following the exact same pass-through pattern asrejectionSignaledatattempt-input-builder.js:101.maxIterations/capLimitsremain the only iteration/budget controls -- this field must gate ONLY the pass->handoff transition, nothing else (no change to iteration-count or write-action-scope logic anywhere in this diff).Deliverables
IterationState.autonomyLevel,IterateLoopDecision.requiresApproval,AbandonReason's new"autonomy_observe_only"variant.decideNextActionWithReasonfor all three levels.IterateLoopInput.autonomyLevelplus its wiring intoIterationStateinsiderunIterateLoopCore.buildAttemptLoopInput'samsPolicySpec.selfLoopAutonomy->autonomyLevelmapping.Test Coverage Requirements
autonomyLevelvalues tested against aselfReview.kind === "pass"state:"auto"->handoff/norequiresApproval;"auto_with_approval"->handoff/requiresApproval: true;"observe"->abandon/abandonReason: "autonomy_observe_only".IterationStatetwice, once withautonomyLevel: undefined(or the field omitted entirely) and once withautonomyLevel: "auto"explicitly, and assertdecideNextActionWithReasonreturns deep-equal decisions for both -- proving the new field is a true no-op when unset, matchingcostCeilingReached's own omitted/explicit-default test pair (packages/loopover-engine/test/iterate-policy.test.ts:114-131).rejectionSignaledandselfReview.kind === "ambiguous"still win over ANYautonomyLevelvalue, including"observe"(i.e. these steps are unreachable-by-autonomy, not merely untested by omission).buildAttemptLoopInput's new mapping needs its own assertion intest/unit/miner-attempt-input-builder.test.ts(existingdescribe("buildAttemptLoopInput (#5132)", ...)block at line 112, following the same pattern as its existing "threads a real rejectionSignaled:true through unchanged" test at line 166) provingamsPolicySpec.selfLoopAutonomyflows unchanged into the builtIterateLoopInput.autonomyLevel.Expected Outcome
An operator's
.loopover-ams.ymlselfLoopAutonomysetting now actually gates whether a clean self-review pass hands off unconditionally, hands off pending approval, or stays observe-only-and-abandons -- with a repo that leaves the field unset behaving byte-identically to pre-this-issue code.Links & Resources
AmsPolicySpec.selfLoopAutonomy), filed as a sibling sub-issue of Decide AMS's own autonomy-level dial for iterate-policy.ts's decideNextAction #6060 -- this issue reads that field.packages/loopover-engine/src/miner/iterate-policy.tspackages/loopover-engine/src/miner/iterate-loop.tspackages/loopover-miner/lib/attempt-input-builder.jspackages/loopover-engine/src/settings/autonomy.ts(autonomyRequiresApproval, the mirrored pattern)test/unit/miner-attempt-input-builder.test.ts(the composer's existing test file)