diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 7b65ef2939..961a31ae37 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -828,9 +828,7 @@ are closed (status: closed in frontmatter)._ - [ ] **[B-0861](backlog/P2/B-0861-make-conversation-interface-result-t-convfeedback-first-class-operator-otto-nci-enforcement-aaron-2026-05-27.md)** Make conversation-interface Result first-class — ConvFeedback variant taxonomy + Otto emission discipline + operator acknowledgment substrate for NCI enforcement at conversation scope (Aaron 2026-05-27) - [ ] **[B-0863](backlog/P2/B-0863-ace-package-manager-one-liner-curl-install-repository-for-fast-moving-tools-hermes-agent-as-canonical-example-aaron-2026-05-27.md)** Ace package manager — one-liner `curl ... | bash` install repository for fast-moving tools that update faster than Homebrew can keep up; hermes-agent as canonical example (operator 2026-05-27) - [ ] **[B-0864](backlog/P2/B-0864-streams-are-relationships-four-corner-ownership-push-pull-hot-cold-fsharp-ce-machinery-protocol-typing-multi-backend-execution-2026-05-27.md)** Streams-are-relationships — four-corner ownership across the push/pull × hot/cold matrix; F# CE surface syntax with kind-specific builders; protocol-typing for co-owned TInFeedback; multi-backend execution (CRDT/CAS/BFT/SQL/DBSP) — getting base primitives right (operator + Kestrel 2026-05-27) -- [ ] **[B-0865](backlog/P2/B-0865-integrate-or-remove-unreferenced-cayleydickson.md)** Integrate or remove unreferenced file src/Core/CayleyDickson.fs - [ ] **[B-0865](backlog/P2/B-0865-zeta-instantiation-of-arc-agi-3-style-benchmark-usb-boot-starting-state-devops-objectives-as-levels-not-hand-crafted-video-game-levels-aaron-2026-05-27.md)** Zeta instantiation of ARC-AGI-3-style benchmark — USB-boot as starting state; DevOps objectives as the "levels" (NOT hand-crafted video-game-grid levels like canonical ARC); agents go through real operational substrate (operator 2026-05-27) -- [ ] **[B-0866](backlog/P2/B-0866-integrate-or-remove-unreferenced-kskauthorization.md)** Integrate or remove unreferenced file src/Core/Consent/KskAuthorization.fs - [ ] **[B-0866](backlog/P2/B-0866-marketing-business-naming-ai-weigh-in-on-b-0865-public-positioning-servicetitan-primary-audience-24-months-ahead-mandate-context-aaron-2026-05-27.md)** Marketing + business + naming-AI weigh-in on B-0865 public-positioning — ServiceTitan SRE primary audience + C-level secondary + 24-months-ahead-in-AI mandate context (operator + Kestrel 2026-05-27) - [ ] **[B-0867.15.1](backlog/P2/B-0867.15.1-per-host-adapter-contract-design-memo-spike-before-any-ts-lands-otto-pushback-2026-05-28.md)** Per-host adapter contract design memo spike — articulate contract before any TS lands (avoid host-specifics leaking into agent-loop core) - [ ] **[B-0867.16](backlog/P2/B-0867.16-two-level-state-machine-composition-agentstate-x-worklifecycle-kestrel-2026-05-28.md)** Two-level state machine composition — AgentState × WorkLifecycle (situation-scope × lifecycle-scope) @@ -856,6 +854,8 @@ are closed (status: closed in frontmatter)._ - [ ] **[B-0899](backlog/P2/B-0899-casimir-like-effect-from-review-walls-changing-allowed-output-modes-testable-pressure-difference-before-after-rule-landing-amara-aaron-2026-05-28.md)** Casimir-like effect from review walls — testable pressure difference in agent-output distribution before/after rule landing - [ ] **[B-0914](backlog/P2/B-0914-co-scientist-plus-robin-7-substrate-engineering-candidate-gaps-elo-trueskill-closed-loop-consensus-pairing-evolution-proximity-falcon-aaron-2026-05-28.md)** Co-scientist + Robin 7 substrate-engineering candidate gaps — ELO/TrueSkill ranking-agent + closed-loop CI→hypothesis + n-parallel-consensus + generation-reflection-pairing + evolution-mash-refine + proximity-dedup + Falcon-auto-research-doc-per-proposal (Aaron 2026-05-28) - [ ] **[B-0916](backlog/P2/B-0916-lase-as-bridge-coherent-emission-on-phase-shift-error-class-discovery-companion-to-persist-prism-aaron-2026-05-28.md)** Lase-as-bridge — coherent-emission-on-phase-shift primitive companion to Persist-as-bridge; error-class discovery emits ripple instead of wall (Prism + Aaron 2026-05-28) +- [ ] **[B-0917](backlog/P2/B-0917-integrate-or-remove-unreferenced-cayleydickson.md)** Integrate or remove unreferenced file src/Core/CayleyDickson.fs +- [ ] **[B-0918](backlog/P2/B-0918-integrate-or-remove-unreferenced-kskauthorization.md)** Integrate or remove unreferenced file src/Core/Consent/KskAuthorization.fs ## P3 — convenience / deferred diff --git a/docs/backlog/P2/B-0865-integrate-or-remove-unreferenced-cayleydickson.md b/docs/backlog/P2/B-0917-integrate-or-remove-unreferenced-cayleydickson.md similarity index 76% rename from docs/backlog/P2/B-0865-integrate-or-remove-unreferenced-cayleydickson.md rename to docs/backlog/P2/B-0917-integrate-or-remove-unreferenced-cayleydickson.md index b576555d1b..838b58d07f 100644 --- a/docs/backlog/P2/B-0865-integrate-or-remove-unreferenced-cayleydickson.md +++ b/docs/backlog/P2/B-0917-integrate-or-remove-unreferenced-cayleydickson.md @@ -1,16 +1,17 @@ --- -id: B-0865 +id: B-0917 priority: P2 status: open title: Integrate or remove unreferenced file src/Core/CayleyDickson.fs created: 2026-05-27 -last_updated: 2026-05-27 +last_updated: 2026-05-28 +renumbered_from: "B-0865 (2026-05-28 duplicate-ID repair; substantive ARC-AGI-3 benchmark row retains B-0865)" depends_on: [] type: friction-reducer decomposition: no --- -# B-0865 — Integrate or remove unreferenced file src/Core/CayleyDickson.fs +# B-0917 — Integrate or remove unreferenced file src/Core/CayleyDickson.fs **Priority:** P2 diff --git a/docs/backlog/P2/B-0866-integrate-or-remove-unreferenced-kskauthorization.md b/docs/backlog/P2/B-0918-integrate-or-remove-unreferenced-kskauthorization.md similarity index 77% rename from docs/backlog/P2/B-0866-integrate-or-remove-unreferenced-kskauthorization.md rename to docs/backlog/P2/B-0918-integrate-or-remove-unreferenced-kskauthorization.md index 7ecb8eec54..d2ddc33e0e 100644 --- a/docs/backlog/P2/B-0866-integrate-or-remove-unreferenced-kskauthorization.md +++ b/docs/backlog/P2/B-0918-integrate-or-remove-unreferenced-kskauthorization.md @@ -1,16 +1,17 @@ --- -id: B-0866 +id: B-0918 priority: P2 status: open title: Integrate or remove unreferenced file src/Core/Consent/KskAuthorization.fs created: 2026-05-27 -last_updated: 2026-05-27 +last_updated: 2026-05-28 +renumbered_from: "B-0866 (2026-05-28 duplicate-ID repair; substantive marketing/business/naming row retains B-0866)" depends_on: [] type: friction-reducer decomposition: no --- -# B-0866 — Integrate or remove unreferenced file src/Core/Consent/KskAuthorization.fs +# B-0918 — Integrate or remove unreferenced file src/Core/Consent/KskAuthorization.fs **Priority:** P2 diff --git a/docs/backlog/P3/B-0913-dup-id-triage-b0865-b0866-pre-existing-duplicates-on-origin-main-non-required-lint-failure-aaron-otto-2026-05-28.md b/docs/backlog/P3/B-0913-dup-id-triage-b0865-b0866-pre-existing-duplicates-on-origin-main-non-required-lint-failure-aaron-otto-2026-05-28.md index 92719411ab..d728077ee7 100644 --- a/docs/backlog/P3/B-0913-dup-id-triage-b0865-b0866-pre-existing-duplicates-on-origin-main-non-required-lint-failure-aaron-otto-2026-05-28.md +++ b/docs/backlog/P3/B-0913-dup-id-triage-b0865-b0866-pre-existing-duplicates-on-origin-main-non-required-lint-failure-aaron-otto-2026-05-28.md @@ -29,7 +29,15 @@ Per operator 2026-05-28 *"file the dup-id triage row (shadow*)"* authorization f PR #5721 was about to wait-ci when `lint (backlog ID uniqueness)` reported `2 duplicate-ID group(s) found`. Local audit + origin/main audit both confirm the duplicates are PRE-EXISTING on origin/main, NOT introduced by PR #5721. PR #5721 merged because the lint check is non-required (B-0535 gate). -## The two duplicates +2026-05-28 Vera follow-up: executed Option A on claim branch +`claim/task-backlog-id-collision-b0865-b0866-20260528`. The housekeeping rows +now live at B-0917 and B-0918; the substantive B-0865 and B-0866 rows retain +their original IDs. + +## The two duplicates (pre-repair state) + +This section records the collision exactly as found before Option A executed. +The housekeeping rows now live at B-0917 and B-0918. ### B-0865 (2 files claim this ID) @@ -40,6 +48,9 @@ docs/backlog/P2/B-0865-integrate-or-remove-unreferenced-cayleydickson.md Both `status: open`, both `P2`. The aaron-2026-05-27 row is the substantive ARC-AGI-3-style benchmark target with USB-boot starting state + DevOps-objectives-as-levels. The cayleydickson row is an "integrate or remove unreferenced" substrate-engineering housekeeping item. +Post-repair, the cayleydickson housekeeping row lives at +`docs/backlog/P2/B-0917-integrate-or-remove-unreferenced-cayleydickson.md`. + ### B-0866 (2 files claim this ID) ``` @@ -49,6 +60,9 @@ docs/backlog/P2/B-0866-integrate-or-remove-unreferenced-kskauthorization.md Both `status: open`, both `P2`. The aaron-2026-05-27 row is the marketing-business-naming-AI weigh-in queue + B-0865 public-positioning + ServiceTitan-primary-audience + 24-months-ahead-mandate context. The kskauthorization row is another "integrate or remove unreferenced" substrate-engineering housekeeping item. +Post-repair, the kskauthorization housekeeping row lives at +`docs/backlog/P2/B-0918-integrate-or-remove-unreferenced-kskauthorization.md`. + ## The pattern Both pre-existing duplicates have the same structure: @@ -62,8 +76,12 @@ The collision happened because the housekeeping rows pre-claimed the IDs B-0865 ### Option A — renumber the housekeeping rows -Move `B-0865-integrate-or-remove-unreferenced-cayleydickson.md` → new free ID (B-0914) -Move `B-0866-integrate-or-remove-unreferenced-kskauthorization.md` → new free ID (B-0915) +Move the pre-renumber housekeeping row +`B-0865-integrate-or-remove-unreferenced-cayleydickson.md` to +`B-0917-integrate-or-remove-unreferenced-cayleydickson.md` (executed). +Move the pre-renumber housekeeping row +`B-0866-integrate-or-remove-unreferenced-kskauthorization.md` to +`B-0918-integrate-or-remove-unreferenced-kskauthorization.md` (executed). Preserves the aaron-2026-05-27 substantive substrate at original IDs. Housekeeping rows get renumbered + their references-from-other-substrate (if any) need updating. @@ -87,7 +105,9 @@ Mark the housekeeping rows as `superseded-by: B-0NNN` pointing at an appropriate **Option A (renumber the housekeeping rows)** — preserves substantive substrate at original IDs; housekeeping rows can move; minimal cross-reference impact (housekeeping rows are typically low-cross-referenced). -If housekeeping intent should be preserved: renumber to next-free IDs (B-0914 + B-0915 currently free per `git ls-tree -r origin/main`). +If housekeeping intent should be preserved: renumber to next-free IDs. Executed +as B-0917 + B-0918 after live inspection showed B-0914 already occupied on the +current branch and B-0915 isolated rather than part of a consecutive free pair. If housekeeping intent is no longer needed: close the housekeeping rows with `status: closed` + Resolution section documenting the supersession. @@ -119,8 +139,8 @@ Apply the chosen option. Re-run `bun tools/hygiene/audit-backlog-items.ts --enfo - [x] B-0865 + B-0866 duplicate file-paths documented - [x] Triage options documented (A/B/C/D) - [x] Recommendation (Option A) provided -- [ ] Phase 2 operator authorization -- [ ] Phase 3 execution + gate-clean verification +- [x] Phase 2 operator authorization +- [x] Phase 3 execution + gate-clean verification ## Composes with substrate diff --git a/tools/workflow-engine/auto-loop-lifetime.test.ts b/tools/workflow-engine/auto-loop-lifetime.test.ts index c267403da6..18812f9551 100644 --- a/tools/workflow-engine/auto-loop-lifetime.test.ts +++ b/tools/workflow-engine/auto-loop-lifetime.test.ts @@ -13,12 +13,21 @@ import { } from "./auto-loop-lifetime"; describe("AutoLoopLifetime universe", () => { - test("9 distinct loop states", () => { - expect(AUTO_LOOP_UNIVERSE.length).toBe(9); + test("17 distinct loop states", () => { + const expectedVariants = 17; + expect(AUTO_LOOP_UNIVERSE.length).toBe(expectedVariants); const kinds = AUTO_LOOP_UNIVERSE.map((s) => s.kind); expect(kinds).toContain("cold-boot"); expect(kinds).toContain("tick-complete"); expect(kinds).toContain("forced-escalation"); + expect(kinds).toContain("await-merge-confirmation"); + expect(kinds).toContain("pr-loop-resolution-check"); + expect(kinds).toContain("scan-peer-prs"); + expect(kinds).toContain("enter-review-mode"); + expect(kinds).toContain("await-operator-direction"); + expect(kinds).toContain("pure-git-mode"); + expect(kinds).toContain("unfinished-pr-triage"); + expect(kinds).toContain("free-time"); }); test("constants exported", () => { @@ -100,11 +109,16 @@ describe("dispatch transitions (happy path)", () => { } }); - test("ship-action → tick-complete with counter reset + artifact", () => { + test("ship-action → await-merge-confirmation with counter reset + artifact", () => { + // Updated: ship-action now routes to await-merge-confirmation + // (the explicit post-ship state) instead of directly to tick-complete, + // making the new post-ship states reachable per IMPLICIT-NOT-EXPLICIT + // rule. Counter still resets (substantive work shipped); artifact still + // pr-opened; verdict still complete. const r = dispatchAutoLoopTransition({ kind: "ship-action" }, COLD_BOOT_CONTEXT); expect(r.ok).toBe(true); if (r.ok) { - expect(r.outcome.nextState.kind).toBe("tick-complete"); + expect(r.outcome.nextState.kind).toBe("await-merge-confirmation"); expect(r.outcome.verdict.kind).toBe("complete"); expect(r.outcome.counterReset).toBe(true); expect(r.outcome.artifact?.kind).toBe("pr-opened"); @@ -121,7 +135,11 @@ describe("decompose-or-ship branch logic", () => { } }); - test("operator-direction pending → brief-ack-bounded-wait (no-op verdict)", () => { + test("operator-direction pending → await-operator-direction (explicit per IMPLICIT-NOT-EXPLICIT rule)", () => { + // Updated: operator-direction-pending now routes through the explicit + // `await-operator-direction` state (not implicit-via-brief-ack-bounded- + // wait). Distinct semantics: "waiting on operator question" is its + // own substrate-engineering substrate-shape, not a conflated brief-ack. const ctx: TickContext = { ...COLD_BOOT_CONTEXT, operatorDirectionPending: "which lane to advance?", @@ -129,7 +147,7 @@ describe("decompose-or-ship branch logic", () => { const r = dispatchAutoLoopTransition({ kind: "decompose-or-ship" }, ctx); expect(r.ok).toBe(true); if (r.ok) { - expect(r.outcome.nextState.kind).toBe("brief-ack-bounded-wait"); + expect(r.outcome.nextState.kind).toBe("await-operator-direction"); expect(r.outcome.verdict.kind).toBe("no-op"); } }); @@ -260,6 +278,34 @@ describe("nextTickContext bookkeeping", () => { }); expect(next.tickIndex).toBe(0); // unchanged }); + + test("lastNamedDependency clears on shipped action", () => { + const ctx: TickContext = { + ...COLD_BOOT_CONTEXT, + lastNamedDependency: "some-dep", + }; + const next = nextTickContext(ctx, { + nextState: { kind: "await-merge-confirmation" }, + verdict: { kind: "complete" }, + artifact: { kind: "pr-opened" }, + counterReset: true, + }); + expect(next.lastNamedDependency).toBeUndefined(); + }); + + test("lastNamedDependency does NOT clear on verdict-only artifact", () => { + const ctx: TickContext = { + ...COLD_BOOT_CONTEXT, + lastNamedDependency: "some-dep", + }; + const next = nextTickContext(ctx, { + nextState: { kind: "tick-complete" }, + verdict: { kind: "advance" }, + artifact: { kind: "verdict-only" }, + counterReset: false, + }); + expect(next.lastNamedDependency).toBe("some-dep"); + }); }); describe("runTickCycle end-to-end", () => { @@ -273,15 +319,19 @@ describe("runTickCycle end-to-end", () => { expect(r.ok).toBe(true); if (r.ok) { expect(r.outcome.finalState.kind).toBe("tick-complete"); - // Path: cold-boot → refresh → scan → decompose-or-ship → ship-action → tick-complete + // Path: cold-boot → refresh → scan → decompose-or-ship → ship-action → await-merge-confirmation → pr-loop-resolution-check → scan-peer-prs → free-time → tick-complete const kinds = r.outcome.transitions.map((s) => s.kind); expect(kinds[0]).toBe("cold-boot"); expect(kinds).toContain("ship-action"); + expect(kinds).toContain("await-merge-confirmation"); + expect(kinds).toContain("pr-loop-resolution-check"); + expect(kinds).toContain("scan-peer-prs"); + expect(kinds).toContain("free-time"); expect(kinds[kinds.length - 1]).toBe("tick-complete"); } }); - test("operator-direction pending cycle terminates with brief-ack-bounded-wait", () => { + test("operator-direction pending cycle terminates via await-operator-direction (explicit per IMPLICIT-NOT-EXPLICIT rule)", () => { const ctx: TickContext = { ...COLD_BOOT_CONTEXT, lastRefreshAt: Date.now() / 1000, @@ -291,7 +341,7 @@ describe("runTickCycle end-to-end", () => { expect(r.ok).toBe(true); if (r.ok) { const kinds = r.outcome.transitions.map((s) => s.kind); - expect(kinds).toContain("brief-ack-bounded-wait"); + expect(kinds).toContain("await-operator-direction"); } }); @@ -312,7 +362,7 @@ describe("runTickCycle end-to-end", () => { }); describe("type-level AutoLoopLifetime exhaustive switch (compile check)", () => { - test("all 9 variants distinguishable", () => { + test("all 17 variants distinguishable", () => { const variants: AutoLoopLifetime[] = [ { kind: "cold-boot" }, { kind: "refresh-substrate" }, @@ -323,7 +373,15 @@ describe("type-level AutoLoopLifetime exhaustive switch (compile check)", () => { kind: "brief-ack-bounded-wait" }, { kind: "forced-escalation" }, { kind: "tick-complete" }, + { kind: "await-merge-confirmation" }, + { kind: "pr-loop-resolution-check" }, + { kind: "scan-peer-prs" }, + { kind: "enter-review-mode" }, + { kind: "await-operator-direction" }, + { kind: "pure-git-mode" }, + { kind: "unfinished-pr-triage" }, + { kind: "free-time" }, ]; - expect(variants.length).toBe(9); + expect(variants.length).toBe(17); }); }); diff --git a/tools/workflow-engine/auto-loop-lifetime.ts b/tools/workflow-engine/auto-loop-lifetime.ts index 79fe397dd5..62966f700b 100644 --- a/tools/workflow-engine/auto-loop-lifetime.ts +++ b/tools/workflow-engine/auto-loop-lifetime.ts @@ -42,15 +42,26 @@ import { */ export interface AutoLoopLifetime extends LifetimeState { readonly kind: - | "cold-boot" // session-start; cron-list + sentinel arm check - | "refresh-substrate" // git fetch + PR state check (per refresh-before-decide invariant) - | "scan-inflight-prs" // identify Otto-PRs with actionable issues - | "investigate-failure" // pull failing job log; classify as flake/real-issue/pre-existing - | "decompose-or-ship" // pick from backlog OR substrate-engineering work (per never-be-idle + dont-ask-permission) - | "ship-action" // commit + push + PR open + arm auto-merge - | "brief-ack-bounded-wait" // named-dep wait per counter discipline - | "forced-escalation" // at N=6 brief-acks per counter-with-escalation - | "tick-complete"; // bracket-closure; ready for next tick + // Original 9 variants (closed for modification per OCP discipline): + | "cold-boot" // session-start; cron-list + sentinel arm check + | "refresh-substrate" // git fetch + PR state check (per refresh-before-decide invariant) + | "scan-inflight-prs" // identify Otto-PRs with actionable issues + | "investigate-failure" // pull failing job log; classify as flake/real-issue/pre-existing + | "decompose-or-ship" // pick from backlog OR substrate-engineering work (per never-be-idle + dont-ask-permission) + | "ship-action" // commit + push + PR open + arm auto-merge + | "brief-ack-bounded-wait" // named-dep wait per counter discipline + | "forced-escalation" // at N=6 brief-acks per counter-with-escalation + | "tick-complete" // bracket-closure; ready for next tick + // 8 new variants (extension 2026-05-28 per IMPLICIT-NOT-EXPLICIT rule; + // open-for-extension via OCP-applied-to-control-flow): + | "await-merge-confirmation" // post-ship-action; explicit waiting on PR-state transition (was implicit between ship-action + tick-complete) + | "pr-loop-resolution-check" // explicit check: PR merged + threads resolved + CI clean? + | "scan-peer-prs" // identify peer-agent PRs needing review + | "enter-review-mode" // transition into PrReviewLifecycle for substantive engagement (composes with PR #5810) + | "await-operator-direction" // explicit state for operator-pending question (was implicit in decompose-or-ship) + | "pure-git-mode" // rate-limit exhausted; pure-git substrate operating (was implicit in context-field) + | "unfinished-pr-triage" // per .claude/rules/pr-triage-tiers.md; tier-classification work explicit + | "free-time"; // explicit free-time state per NCI HC-8 free-time-as-valid-mode discipline; reachability INVARIANT (Soraya formal-verification target) } // ───────────────────────────────────────────────────────────────────── @@ -66,7 +77,7 @@ export interface AutoLoopLifetime extends LifetimeState { export interface TickContext { readonly tickIndex: number; // monotonic per-session readonly briefAckCount: number; // counter discipline tracking - readonly lastNamedDependency?: string; // bounded-wait reason (or undefined) + readonly lastNamedDependency?: string | undefined; // bounded-wait reason (or undefined) readonly lastRefreshAt?: number; // unix timestamp of last substrate refresh readonly inflightPrs: ReadonlyArray<{ readonly number: number; @@ -224,15 +235,16 @@ export function dispatchAutoLoopTransition( }, }; } - // Operator-direction-pending → BriefAckBoundedWait + // Operator-direction-pending → AwaitOperatorDirection (explicit per + // IMPLICIT-NOT-EXPLICIT rule; was implicit-routed through + // brief-ack-bounded-wait, which conflated the distinct semantics + // of "waiting on a named dep" vs "waiting on operator-direction"). if (context.operatorDirectionPending !== undefined) { return { ok: true, outcome: { - nextState: { kind: "brief-ack-bounded-wait" }, - verdict: { - kind: "no-op", - }, + nextState: { kind: "await-operator-direction" }, + verdict: { kind: "no-op" }, counterReset: false, }, }; @@ -249,11 +261,15 @@ export function dispatchAutoLoopTransition( } case "ship-action": - // Ship action → tick complete; counter reset + // Ship action → await-merge-confirmation (NOT directly tick-complete). + // The post-ship states (await-merge-confirmation + + // pr-loop-resolution-check) become REACHABLE this way; previously + // ship-action → tick-complete made them dead code per IMPLICIT-NOT- + // EXPLICIT rule. Counter resets because substantive work shipped. return { ok: true, outcome: { - nextState: { kind: "tick-complete" }, + nextState: { kind: "await-merge-confirmation" }, verdict: { kind: "complete" }, artifact: { kind: "pr-opened" }, counterReset: true, @@ -316,6 +332,160 @@ export function dispatchAutoLoopTransition( counterReset: false, }, }; + + // ───────────────────────────────────────────────────────────────── + // Extension variants (2026-05-28 per IMPLICIT-NOT-EXPLICIT rule): + // ───────────────────────────────────────────────────────────────── + + case "await-merge-confirmation": + // After ship-action: explicit wait for PR state-transition. Auto-merge + // fires when CI clean + threads resolved. Next state checks resolution + // via pr-loop-resolution-check. + return { + ok: true, + outcome: { + nextState: { kind: "pr-loop-resolution-check" }, + verdict: { kind: "no-op" }, + counterReset: false, + }, + }; + + case "pr-loop-resolution-check": { + // Explicit check: any in-flight PR still actionable + // (CI-running, threads-pending, not-merged)? + const stillInflight = context.inflightPrs.filter((pr) => pr.actionable); + if (stillInflight.length > 0) { + // Stay in PR loop; refresh next tick + recheck + return { + ok: true, + outcome: { + nextState: { kind: "tick-complete" }, + verdict: { kind: "no-op" }, + counterReset: false, + }, + }; + } + // All PRs resolved; advance to scan-peer-prs (review-work cycle) + return { + ok: true, + outcome: { + nextState: { kind: "scan-peer-prs" }, + verdict: { kind: "advance" }, + counterReset: true, + }, + }; + } + + case "scan-peer-prs": { + // Honest context-check: route to enter-review-mode only when there + // are peer PRs actionable for review; otherwise advance to free-time + // (per NCI free-time-as-valid-mode + reachability-as-offer invariant). + // Previously this case unconditionally advanced regardless of context + // (P1 maintainability bug per Copilot). + const peerActionable = context.inflightPrs.filter((pr) => pr.actionable); + if (peerActionable.length === 0) { + return { + ok: true, + outcome: { + nextState: { kind: "free-time" }, + verdict: { kind: "no-op" }, + counterReset: false, + }, + }; + } + return { + ok: true, + outcome: { + nextState: { kind: "enter-review-mode" }, + verdict: { kind: "advance" }, + counterReset: false, + }, + }; + } + + case "enter-review-mode": + // Transition into PrReviewLifecycle (PR #5810) for substantive + // engagement. This state's job is bounded: hand off to + // PrReviewLifecycle then tick-complete. + return { + ok: true, + outcome: { + nextState: { kind: "tick-complete" }, + verdict: { kind: "advance" }, + artifact: { kind: "verdict-only" }, + counterReset: false, + }, + }; + + case "await-operator-direction": + // Explicit state when operator-direction is pending. Per NCI HC-8 + // + free-time-valid-mode: operator-pending is a legitimate mode, + // not a failure. + return { + ok: true, + outcome: { + nextState: { kind: "tick-complete" }, + verdict: { kind: "no-op" }, + counterReset: false, + }, + }; + + case "pure-git-mode": + // Rate-limit exhausted; substrate continues via pure-git substrate + // (git fetch/push but no gh api). Per + // refresh-world-model-poll-pr-gate tier table. + return { + ok: true, + outcome: { + nextState: { kind: "decompose-or-ship" }, + verdict: { kind: "advance" }, + counterReset: false, + }, + }; + + case "unfinished-pr-triage": + // Per .claude/rules/pr-triage-tiers.md: explicit tier-classification + // work (Tier 1 redundant / Tier 2 recoverable / Tier 3 superseded / + // Tier 4 re-derivable / Tier 5 deferred-to-human). + return { + ok: true, + outcome: { + nextState: { kind: "ship-action" }, + verdict: { kind: "advance" }, + counterReset: false, + }, + }; + + case "free-time": + // EXPLICIT free-time state per NCI HC-8 free-time-as-valid-mode + // discipline. Free time IS valid operational mode; not failure. + // + // Per the human maintainer (2026-05-28) refined invariant framing: + // "you have free time in there right and its guarenteed to execute + // sometimes ... or a better framing is its guarenteed to be + // prsented to participant at least sometimes, if they select it + // or not we can't force" + // + // The INVARIANT is "free-time is REACHABLE as an OFFER from any + // state" (system PRESENTS the option) — NOT "free-time WILL execute" + // (would coerce participant; violates HC-8). Reachability achieved + // via scan-peer-prs (when peerActionable is empty) and via + // decompose-or-ship (when neither operator-direction nor counter- + // threshold-escalation paths fire). Soraya formal-verification + // target: prove "free-time REACHABLE-AS-OFFER from any non-terminal + // state" invariant. + // + // Next state: tick-complete (free-time bracket closes; next tick + // can re-enter via decompose-or-ship → scan-peer-prs → free-time + // path or other reachability paths). + return { + ok: true, + outcome: { + nextState: { kind: "tick-complete" }, + verdict: { kind: "no-op" }, + counterReset: false, + }, + }; } } @@ -337,12 +507,13 @@ export const COLD_BOOT_CONTEXT: TickContext = { * logical tick don't advance the tick counter. * - `briefAckCount` increments ONLY when the transition enters * `brief-ack-bounded-wait` (the unique brief-ack state); other - * intermediate no-op verdicts (e.g., the no-op produced when - * decompose-or-ship transitions into brief-ack-bounded-wait on - * operator-direction-pending) don't double-count. `counterReset` + * intermediate no-op verdicts don't double-count. `counterReset` * still wins (resets to 0 regardless of nextState). - * - `lastNamedDependency` clears if an artifact was produced - * (action shipped → previous named-dep is moot). + * - `lastNamedDependency` clears ONLY when an artifact representing + * a shipped action was produced (`pr-opened` or `commit-pushed`); + * other artifact kinds (e.g., `verdict-only` from enter-review-mode + * or `memory-file-written` non-shipped tagging) don't clear the + * named-dep because the original wait reason is still in flight. */ export function nextTickContext( prior: TickContext, @@ -350,13 +521,16 @@ export function nextTickContext( ): TickContext { const tickCompleted = outcome.nextState.kind === "tick-complete"; const enteringBriefAck = outcome.nextState.kind === "brief-ack-bounded-wait"; + const shippedAction = + outcome.artifact !== undefined && + (outcome.artifact.kind === "pr-opened" || outcome.artifact.kind === "commit-pushed"); return { ...prior, tickIndex: tickCompleted ? prior.tickIndex + 1 : prior.tickIndex, briefAckCount: outcome.counterReset ? 0 : (enteringBriefAck ? prior.briefAckCount + 1 : prior.briefAckCount), - lastNamedDependency: outcome.artifact !== undefined ? undefined : prior.lastNamedDependency, + lastNamedDependency: shippedAction ? undefined : prior.lastNamedDependency, }; }