Skip to content

fix: harden sync recovery and reviewer contracts#1

Merged
Boulea7 merged 1 commit intomainfrom
fix/recovery-reviewer-contracts
Mar 18, 2026
Merged

fix: harden sync recovery and reviewer contracts#1
Boulea7 merged 1 commit intomainfrom
fix/recovery-reviewer-contracts

Conversation

@Boulea7
Copy link
Owner

@Boulea7 Boulea7 commented Mar 18, 2026

Summary

  • harden command outcome classification so explicit exit codes win over incidental PASS/FAIL wording in stdout
  • make durable sync and continuity startup compilation safe under tiny line budgets and keep stale local continuity goals from overriding fresh shared goals
  • improve recovery and reviewer contracts with corrupted-state fallback, already-processed recovery cleanup, richer JSON assertions, and refreshed milestone/docs bookkeeping

Validation

  • pnpm lint
  • pnpm test
  • pnpm build
  • pnpm exec tsx src/cli.ts doctor --json
  • pnpm exec tsx src/cli.ts memory --json --recent 5
  • pnpm exec tsx src/cli.ts session save --json
  • pnpm exec tsx src/cli.ts session load --json
  • pnpm exec tsx src/cli.ts session status --json
  • pnpm exec tsx src/cli.ts audit --json

Notes

  • cam doctor --json still reports memories and codex_hooks as under development and disabled.
  • cam audit --json completed with high=0 and medium=0.
  • This PR also updates changelog, progress tracking, reviewer handoff, native migration links, and local-ignore guidance to match the repaired reviewer contract.

Summary by Sourcery

Harden durable sync, recovery, and session continuity behavior while tightening reviewer-facing contracts and docs.

Bug Fixes:

  • Treat mixed PASS/FAIL command output as failure and prefer explicit exit codes over incidental stdout wording.
  • Handle corrupted durable-sync processed state by degrading to an empty view so future syncs can proceed.
  • Ensure stale local continuity goals do not override fresher shared project goals when merging continuity state.
  • Prevent tiny startup and continuity line budgets from emitting partial scope or section blocks that mislead reviewers.
  • Allow matching sync recovery markers to clear correctly even when the rollout is already recorded as processed.

Enhancements:

  • Add explicit recovery provenance to durable sync audit entries and expose richer recovery marker details in JSON/text reviewer surfaces.
  • Tighten heuristic continuity summarization, including better Chinese next-step extraction guards and more conservative project-note capture.
  • Adjust continuity and startup compilation to respect very small line budgets while still tracking loaded sources accurately.
  • Broaden test coverage for invalid session scopes, rollout preconditions, corrupted recovery/processed state, JSON clear/status paths, and recovery flows.
  • Increase timeouts for slower audit, project-context, and session-command tests to keep CI stable without diverging from suite defaults.

Documentation:

  • Refresh changelog, progress log, reviewer handoff brief, and native migration docs to reflect the new recovery behavior, hardened reviewer contract, and updated official-doc links.

Tests:

  • Add regression tests for command outcome classification, corrupted durable-sync processed state, tiny startup/continuity budgets, stale goal clearing, Chinese evidence extraction, sync recovery flows, and session guardrail errors.

Summary by cubic

Hardens durable sync recovery and session continuity so reviewer surfaces stay reliable under edge cases. Prefer explicit exit codes, handle corrupted state safely, respect tiny line budgets, and mark audit entries with recovery provenance.

  • Bug Fixes

    • Command classification now prefers explicit exit codes; mixed PASS/FAIL output counts as failure.
    • Corrupted durable-sync state.json degrades to an empty state instead of blocking sync.
    • Startup and continuity compilers respect very small line budgets; no partial headers or blocks are emitted.
    • Matching sync recovery markers self-clear even if the rollout is already processed; identity ignores size/mtime for recovery clearing.
    • Heuristic continuity no longer carries over stale local goals when the latest goal is shared.
  • New Features

    • Sync audit entries include isRecovery and show a [recovery] tag in text output.
    • Continuity/sync recovery JSON surfaces are richer and assert more fields.
    • Added guards to Chinese next-step extraction (skip short captures and common connector fragments).

Written for commit 7aa24a9. Summary will update on new commits.

Summary by CodeRabbit

Release Notes

  • New Features

    • Added recovery mechanism to track and manage sync operations across sessions.
    • Improved command output classification with explicit exit-code detection for success and failure states.
  • Bug Fixes

    • Corrupted state files no longer prevent operations; graceful fallback to default state implemented.
    • Enhanced pattern matching to filter out unintended Chinese discourse markers and short captures.
  • Changed

    • Refined project note classification criteria.
    • Improved memory allocation strategy for resource-constrained startup scenarios.
    • Strengthened error handling for invalid operations.
  • Documentation

    • Updated milestone and progress tracking documentation.
    • Refreshed configuration reference links.

@sourcery-ai
Copy link

sourcery-ai bot commented Mar 18, 2026

Reviewer's Guide

This PR hardens command outcome classification, durable sync/continuity recovery behavior, and startup/continuity compilation under tight line budgets, while enriching recovery/audit JSON contracts and updating docs/reviewer guidance accordingly.

Sequence diagram for sync rollout processing with recovery markers

sequenceDiagram
  participant CLI
  participant SyncService
  participant MemoryStore
  participant RecoveryMatcher as matchesSyncRecoveryRecord
  participant AuditBuilder as buildMemorySyncAuditEntry

  CLI->>SyncService: processRollout(rolloutPath, evidence, force)
  SyncService->>MemoryStore: getProcessedRolloutIdentity(rolloutPath, evidence)
  MemoryStore-->>SyncService: processedIdentity

  SyncService->>MemoryStore: readSyncRecoveryRecord()
  MemoryStore-->>SyncService: existingRecoveryRecord or null

  SyncService->>RecoveryMatcher: matchesSyncRecoveryRecord(existingRecoveryRecord, identity)
  RecoveryMatcher-->>SyncService: isRecovery boolean

  alt rollout already processed and not force
    SyncService->>MemoryStore: hasProcessedRollout(processedIdentity)
    MemoryStore-->>SyncService: true

    alt isRecovery is true
      SyncService->>MemoryStore: clearSyncRecoveryRecordBestEffort(identity)
      MemoryStore-->>SyncService: void
    end

    SyncService->>AuditBuilder: buildMemorySyncAuditEntry(status skipped, skipReason already_processed, isRecovery isRecovery, operations undefined)
    AuditBuilder-->>SyncService: MemorySyncAuditEntry
    SyncService->>MemoryStore: appendSyncAuditEntry(entry)
    MemoryStore-->>SyncService: void
    SyncService-->>CLI: SyncResult status skipped
  else rollout not yet processed or forced
    SyncService->>MemoryStore: hasProcessedRollout(processedIdentity)
    MemoryStore-->>SyncService: false

    SyncService->>MemoryStore: writeSyncRecoveryRecord(record)
    MemoryStore-->>SyncService: void

    SyncService->>MemoryStore: markRolloutProcessed(processedIdentity)
    MemoryStore-->>SyncService: void

    SyncService->>AuditBuilder: buildMemorySyncAuditEntry(status success or failure, isRecovery isRecovery, operations appliedOperations)
    AuditBuilder-->>SyncService: MemorySyncAuditEntry
    SyncService->>MemoryStore: appendSyncAuditEntry(entry)
    MemoryStore-->>SyncService: void

    SyncService->>MemoryStore: clearSyncRecoveryRecordBestEffort(identity)
    MemoryStore-->>SyncService: void

    SyncService-->>CLI: SyncResult status success or failure
  end
Loading

Class diagram for updated sync recovery and audit types

classDiagram
  class SyncService {
    - project Project
    - store MemoryStore
    - configuredExtractorName string
    - sessionSource SessionSource
    + processRollout(rolloutPath string, evidence SyncEvidence, force boolean) Promise~SyncResult~
    + getProcessedRolloutIdentity(rolloutPath string, evidence SyncEvidence) Promise~ProcessedRolloutIdentity~
    + clearSyncRecoveryRecordBestEffort(identity SyncRecoveryIdentity) Promise~void~
  }

  class MemoryStore {
    + paths SyncStorePaths
    + getSyncState() Promise~SyncState~
    + hasProcessedRollout(identity ProcessedRolloutIdentity) Promise~boolean~
    + markRolloutProcessed(identity ProcessedRolloutIdentity) Promise~void~
    + readSyncRecoveryRecord() Promise~SyncRecoveryRecord or null~
    + writeSyncRecoveryRecord(record SyncRecoveryRecord) Promise~void~
    + clearSyncRecoveryRecord() Promise~void~
    + appendSyncAuditEntry(entry MemorySyncAuditEntry) Promise~void~
  }

  class SyncRecoveryRecord {
    + projectId string
    + worktreeId string
    + rolloutPath string
    + sessionId string
  }

  class SyncRecoveryIdentity {
    + projectId string
    + worktreeId string
    + rolloutPath string
    + sessionId string
  }

  class ProcessedRolloutIdentity {
    + projectId string
    + worktreeId string
    + rolloutPath string
    + sessionId string
    + sizeBytes number
    + mtimeMs number
  }

  class MemorySyncAuditEntry {
    + appliedAt string
    + projectId string
    + worktreeId string
    + rolloutPath string
    + sessionId string
    + sessionSource string
    + status MemorySyncAuditStatus
    + skipReason MemorySyncAuditSkipReason
    + isRecovery boolean
    + appliedCount number
    + scopesTouched MemoryScope[]
    + resultSummary string
    + operations MemoryOperation[]
  }

  class BuildMemorySyncAuditEntryOptions {
    + project Project
    + rolloutPath string
    + processedIdentity ProcessedRolloutIdentity
    + status MemorySyncAuditStatus
    + sessionSource string
    + appliedAt string
    + sessionId string
    + skipReason MemorySyncAuditSkipReason
    + isRecovery boolean
    + operations MemoryOperation[]
  }

  class MemorySyncAuditStatus {
  }

  class MemorySyncAuditSkipReason {
  }

  class MemoryScope {
  }

  class MemoryOperation {
  }

  class Project {
    + projectId string
    + worktreeId string
  }

  class SessionSource {
    + name string
  }

  class SyncEvidence {
    + sessionId string
  }

  class SyncStorePaths {
    + stateFile string
  }

  class SyncState {
  }

  class normalizeSyncState {
    + normalizeSyncState(value SyncState or null) SyncState
  }

  SyncService --> MemoryStore : uses
  SyncService --> Project : uses
  SyncService --> SessionSource : uses
  SyncService --> ProcessedRolloutIdentity : builds
  SyncService --> SyncRecoveryIdentity : clears recovery
  SyncService --> MemorySyncAuditEntry : logs

  MemoryStore --> SyncRecoveryRecord : reads_writes
  MemoryStore --> ProcessedRolloutIdentity : stores
  MemoryStore --> SyncState : returns
  MemoryStore --> SyncStorePaths : owns

  SyncRecoveryRecord <.. SyncRecoveryIdentity : matched_by
  ProcessedRolloutIdentity <.. SyncRecoveryRecord : recovery_uses_subset

  MemorySyncAuditEntry <.. BuildMemorySyncAuditEntryOptions : built_from
  MemorySyncAuditEntry --> MemoryScope : touches
  MemorySyncAuditEntry --> MemoryOperation : includes

  normalizeSyncState --> SyncState : returns
  MemoryStore ..> normalizeSyncState : calls

  Project o-- ProcessedRolloutIdentity
  Project o-- SyncRecoveryRecord
  Project o-- MemorySyncAuditEntry
Loading

Class diagram for session continuity state merging changes

classDiagram
  class SessionContinuityState {
    + updatedAt string
    + status string
    + sourceSessionId string
    + goal string
    + confirmedWorking string[]
    + triedAndFailed string[]
    + notYetTried string[]
    + incompleteNext string[]
    + filesDecisionsEnvironment string[]
  }

  class SessionContinuityLayerSummary {
    + goal string
    + confirmedWorking string[]
    + triedAndFailed string[]
    + notYetTried string[]
    + incompleteNext string[]
    + filesDecisionsEnvironment string[]
  }

  class sanitizeSessionContinuityLayerSummary {
    + sanitizeSessionContinuityLayerSummary(summary SessionContinuityLayerSummary) SessionContinuityLayerSummary
  }

  class sanitizeList {
    + sanitizeList(items string[], maxItems number, maxChars number) string[]
  }

  class sanitizeFailureList {
    + sanitizeFailureList(items string[]) string[]
  }

  class applySessionContinuityLayerSummary {
    + applySessionContinuityLayerSummary(base SessionContinuityState, summary SessionContinuityLayerSummary, sourceSessionId string) SessionContinuityState
  }

  class buildLayerSummary {
    + buildLayerSummary(existing SessionContinuityLayerSummary, next SessionContinuityLayerSummary) SessionContinuityLayerSummary
  }

  class heuristicSummary {
    + heuristicSummary(existingProject SessionContinuityLayerSummary, existingLocal SessionContinuityLayerSummary) SessionContinuityEvidenceBuckets
  }

  class SessionContinuityEvidenceBuckets {
    + project SessionContinuityLayerSummary
    + projectLocal SessionContinuityLayerSummary
  }

  applySessionContinuityLayerSummary --> SessionContinuityState : returns
  applySessionContinuityLayerSummary --> SessionContinuityLayerSummary : uses
  applySessionContinuityLayerSummary ..> sanitizeSessionContinuityLayerSummary : calls
  applySessionContinuityLayerSummary ..> sanitizeList : merges_lists
  applySessionContinuityLayerSummary ..> sanitizeFailureList : merges_failures

  buildLayerSummary --> SessionContinuityLayerSummary : returns
  buildLayerSummary ..> sanitizeSessionContinuityLayerSummary : uses_existing
  buildLayerSummary ..> SessionContinuityLayerSummary : next_input

  heuristicSummary --> SessionContinuityEvidenceBuckets : returns
  heuristicSummary ..> buildLayerSummary : builds_layers

  SessionContinuityEvidenceBuckets --> SessionContinuityLayerSummary : contains

  note for applySessionContinuityLayerSummary "Now prefers sanitized goal from summary, concatenates new and base lists, and sanitizes within limits"
Loading

Flow diagram for command outcome classification precedence

flowchart TD
  A[Start classifyCommandOutcome] --> B[toolCall.output exists?]
  B -- no --> C[Return unknown]
  B -- yes --> D[Check explicit failure exit code pattern]

  D -- match --> E[Return failure]
  D -- no match --> F[Check explicit success exit code pattern]

  F -- match --> G[Return success]
  F -- no match --> H[Check generic failure pattern]

  H -- match --> I[Return failure]
  H -- no match --> J[Check generic success pattern]

  J -- match --> K[Return success]
  J -- no match --> L[Return unknown]

  subgraph Patterns
    D
    F
    H
    J
  end
Loading

Flow diagram for startup and continuity compilation with line budgets

flowchart TD
  subgraph SharedHelper
    X1["appendWithinBudget(lines, blockLines, maxLines, minimumLines)"] --> X2[Remaining lines >= minimumLines?]
    X2 -- no --> X3[Return 0]
    X2 -- yes --> X4[Append each line until maxLines]
    X4 --> X5[Return appended count]
  end

  subgraph StartupMemory
    A[Start compileStartupMemory] --> A1[lines = empty]
    A1 --> A2[Append preamble via appendWithinBudget]
    A2 --> A3[For each scope project local, project, global]
    A3 --> A4[Build scopeBlock with heading, file path, quoted contents]
    A4 --> A5[Append scopeBlock via appendWithinBudget with minimumLines 4]
    A5 -- appended 0 --> A6[Break scopes loop]
    A5 -- appended >= 4 --> A7[Track sourceFile]
    A7 --> A3
    A6 --> A8[Collect topic refs]
    A3 --> A8
    A8 --> A9[Has any topic ref?]
    A9 -- no --> A14[Finalize text and lineCount]
    A9 -- yes --> A10[Take firstTopicRef]
    A10 --> A11[Build topicHeaderBlock with header, guidance, first topic]
    A11 --> A12[Append via appendWithinBudget with minimumLines 3]
    A12 -- appended >= 3 --> A13[Push firstTopicRef, append remaining topic refs one per line via appendWithinBudget]
    A12 -- appended < 3 --> A14
    A13 --> A14[Finalize text and lineCount]
  end

  subgraph SessionContinuity
    B[Start compileSessionContinuity] --> B1[lines = empty]
    B1 --> B2[Append preamble via appendWithinBudget]
    B2 --> B3[For each source file]
    B3 --> B4[Append - Source line via appendWithinBudget]
    B4 -- appended 0 --> B5[Break file loop]
    B4 -- appended > 0 --> B3
    B5 --> B6[If any source files, append blank line via appendWithinBudget]
    B6 --> B7[For each section title and items]
    B7 --> B8[Build block: heading, quoted items, blank line]
    B8 --> B9[Append block via appendWithinBudget with minimumLines 2]
    B9 -- appended 0 --> B10[Break sections loop]
    B9 -- appended > 0 --> B7
    B10 --> B11[Join lines, trim, compute lineCount]
  end
Loading

File-Level Changes

Change Details Files
Tightened command outcome classification so explicit exit codes dominate and mixed PASS/FAIL output is treated as failure.
  • Refined success/failure regexes to distinguish explicit exit codes from incidental PASS/FAIL wording and count numeric error phrases more precisely.
  • Introduced explicit success/failure exit code patterns and reordered classification logic so failure exit codes win, then success exit codes, then failure markers, then success markers.
  • Added regression tests that cover mixed PASS/FAIL output, success with incidental FAIL wording, and in-progress/unknown cases.
src/lib/extractor/command-utils.ts
test/command-utils.test.ts
Made durable sync resilient to corrupted processed state and enriched sync recovery/audit semantics, including recovery provenance.
  • Wrapped sync state loading in a try/catch to fall back to an empty normalized state when the state file is missing or corrupted.
  • Tracked whether a sync run is a recovery follow-through via matchesSyncRecoveryRecord and propagated an isRecovery flag into audit entries.
  • Cleared matching sync recovery markers when an already-processed rollout is retried without force, and included isRecovery on skipped audit entries for such cases.
  • Updated audit entry building/parsing/formatting to persist and render isRecovery, including a '[recovery]' tag in text output.
  • Added tests for corrupted processed state recovery, already-processed recovery cleanup, recovery-flagged audit entries, and richer JSON assertions for pending sync recovery.
src/lib/domain/memory-store.ts
src/lib/domain/sync-service.ts
src/lib/domain/memory-sync-audit.ts
src/lib/domain/recovery-records.ts
src/lib/types.ts
test/sync-service.test.ts
test/memory-store.test.ts
test/memory-command.test.ts
Adjusted session continuity summarization/merging so stale local goals don’t override shared goals, while preserving and sanitizing lists and improving evidence extraction.
  • Changed applySessionContinuityLayerSummary to set goal directly from the sanitized summary (clearing it when empty) and to prepend new list items before existing ones with sanitization, instead of merging via mergeSessionContinuityStates.
  • Modified heuristicSummary and buildLayerSummary so the project-local heuristic summary intentionally clears the local goal and only sets a goal when one is explicitly provided in the next summary.
  • Expanded project note patterns to avoid treating generic 'prefer' chat as project notes and narrowed keywords to operational/environment cues.
  • Strengthened evidence extraction to drop short (<10 char) captures and Chinese narrative connector fragments from next-step buckets.
  • Added tests for clearing stale local goals, conservative evidence extraction (including Chinese guardrails), and ignoring generic preference-only chat for project notes.
src/lib/domain/session-continuity.ts
src/lib/extractor/session-continuity-summarizer.ts
src/lib/extractor/session-continuity-evidence.ts
test/session-continuity.test.ts
Hardened startup memory and session continuity compilation to respect very small line budgets without emitting partial sections or scope headers.
  • Introduced shared-style appendWithinBudget helpers in startup-memory and session-continuity modules to append blocks only when a minimum number of lines can fit.
  • Reworked compileStartupMemory to cap the preamble, skip partial scope blocks when quoted memory cannot fit, and only track sourceFiles/topicFiles when a full header+content block was appended.
  • Reworked compileSessionContinuity to build the preamble and sections via appendWithinBudget, ensuring section headers and minimal content fit together and that tiny budgets may omit later sections entirely.
  • Adjusted tests and JSON surfaces so startup/continuity behavior with tight budgets does not report partially loaded scopes and remains internally consistent.
  • Added focused tests that validate preamble-only outputs, skipping of partial scope blocks, and line-count caps under small budgets.
src/lib/domain/startup-memory.ts
src/lib/domain/session-continuity.ts
test/memory-store.test.ts
test/memory-command.test.ts
test/session-continuity.test.ts
Improved session command and continuity recovery contracts, including richer JSON payloads and guardrails on invalid inputs and missing/bad rollouts.
  • Extended pendingContinuityRecovery JSON shape to include sessionId/sourceSessionId, scope, writtenPaths, preferred/actual path, and evidenceCounts, and updated tests to assert these details on load/status flows.
  • Ensured unrelated continuity recovery markers are not cleared on successful save and that corrupted recovery markers are ignored rather than crashing load/status.
  • Increased timeouts for slower recovery-heavy session-command, audit, and project-context tests from 15s to 30s for more stable CI/local runs.
  • Added new runSession tests for invalid scope values, missing rollout for save, malformed rollout input, and clear --json behavior, plus richer assertions on recovery markers.
  • Kept recovery markers separate from audit streams while expanding coverage around corrupted-state fallback and recovery cleanup behavior.
test/session-command.test.ts
test/session-continuity.test.ts
test/audit.test.ts
test/project-context.test.ts
Updated documentation, changelog, and reviewer handoff notes to reflect the new recovery and reviewer contracts and to refresh external doc links.
  • Populated the Unreleased and 0.1.0-alpha.22 sections in CHANGELOG with details on new tests, behavior changes, fixes, and review focus points.
  • Advanced progress-log milestones to Phase 22, documented recovery follow-through work and post-alpha.22 review hardening, and adjusted future milestone descriptions.
  • Refreshed reviewer-handoff and AGENTS checklist text to mark Milestone 22 and post-alpha.22 hardening as complete and to call out resilience around corrupted state and tiny budgets.
  • Updated Codex and Claude documentation links to current URLs and added HTML comments noting when the links were last verified and where canonical docs live.
  • Updated the next-phase brief to target Milestone 23 and aligned narrative with the new phase naming.
CHANGELOG.md
docs/progress-log.md
docs/next-phase-brief.md
docs/reviewer-handoff.md
docs/native-migration.md
docs/native-migration.en.md
docs/claude-reference.md
AGENTS.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link

coderabbitai bot commented Mar 18, 2026

📝 Walkthrough

Walkthrough

This PR introduces recovery marker tracking for sync operations, implements budget-aware memory compilation with graceful degradation, adds corrupted state handling, enhances command classification with explicit exit-code detection, and applies Chinese language safeguards to continuity extraction. Comprehensive test coverage validates recovery flows, state resilience, and budget constraints.

Changes

Cohort / File(s) Summary
Recovery System
src/lib/domain/recovery-records.ts, src/lib/domain/sync-service.ts, src/lib/domain/memory-sync-audit.ts, src/lib/types.ts
Introduces isRecovery flag to track when syncs recover from prior failures; adds matchesSyncRecoveryRecord function; integrates recovery-awareness into sync flow including audit entry tagging and recovery marker clearance.
Corrupted State Resilience
src/lib/domain/memory-store.ts
Wraps getSyncState JSON read with try/catch, falling back to empty state on corruption rather than propagating errors.
Budget-Aware Compilation
src/lib/domain/session-continuity.ts, src/lib/domain/startup-memory.ts
Introduces appendWithinBudget utility to emit content blocks respecting line limits; refactors both modules to gracefully skip/truncate sections when budgets are exhausted.
Command Classification
src/lib/extractor/command-utils.ts
Adds explicit exit-code based success/failure detection (0 vs. non-zero) prioritized before pattern matching; expands failure patterns to detect numeric error counts.
Extraction Safeguards
src/lib/extractor/session-continuity-evidence.ts, src/lib/extractor/session-continuity-summarizer.ts
Filters out Chinese discourse connectors (如、而是、但是、因为、所以、etc.) and short captures in pattern extraction; adjusts project-note keywords and clears stale local goals on layer summaries.
Documentation & Config
.gitignore, AGENTS.md, CHANGELOG.md, docs/*
Expands .gitignore with .local. pattern; documents Milestone 22 recovery follow-through and post-alpha.22 review hardening; updates version references to alpha.22 and adds release notes structure.
Test Coverage
test/*
Adds extensive tests for recovery marker handling, corrupted state resilience, budget-aware startup/continuity compilation, mixed PASS/FAIL command classification, Chinese marker filtering, and continuity layer clearing; increases timeouts from 15s to 30s for slower test cases.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/CLI
    participant Sync as SyncService
    participant Recovery as Recovery Records
    participant Store as MemoryStore
    participant Audit as Audit Entries

    Client->>Sync: initiate sync with rollout
    Sync->>Recovery: check existing recovery record<br/>matchesSyncRecoveryRecord()
    Recovery-->>Sync: isRecovery = true/false
    
    alt isRecovery && already-processed
        Sync->>Recovery: clear recovery marker (best-effort)
        Recovery-->>Sync: cleared
    end
    
    Sync->>Store: read sync state
    Store->>Store: guard with try/catch
    alt JSON parse fails
        Store-->>Sync: return empty state
    else success
        Store-->>Sync: return parsed state
    end
    
    alt already-processed
        Sync->>Audit: build skip entry with isRecovery flag
        Audit-->>Sync: tagged audit entry
    else apply operations
        Sync->>Audit: apply ops & build entry with isRecovery flag
        Audit-->>Sync: tagged audit entry
    end
    
    Sync-->>Client: return result with recovery context
Loading
sequenceDiagram
    participant App as Application
    participant Compiler as Memory Compiler
    participant Budget as Budget Manager
    participant Output as Output Buffer

    App->>Compiler: compile memory with budget X
    
    Compiler->>Budget: appendWithinBudget(preamble, maxLines)
    Budget->>Output: add lines if within budget
    Budget-->>Compiler: return lines added
    
    loop for each memory block/section
        Compiler->>Budget: appendWithinBudget(block, maxLines, minimumLines)
        alt lines added > 0
            Budget->>Output: append block
            Budget-->>Compiler: return count
        else budget exhausted
            Compiler->>Compiler: break loop
        end
    end
    
    Compiler-->>App: return compiled memory<br/>respecting budget constraints
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A recovery hop through budget-wise code,
Chinese safeguards on extraction's road,
Corrupted states now handled with grace,
Sync markers mark each resilient trace!
Budgets embraced, no lines left to spare,
Alpha.22 blooms with recovery care. 🌿

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: harden sync recovery and reviewer contracts' is specific and clearly summarizes the main focus of the PR—strengthening recovery mechanisms and reviewer-facing contracts.
Description check ✅ Passed The PR description is comprehensive and well-structured. It includes a clear summary of changes, detailed validation steps, notes, and organized documentation of bug fixes and enhancements. However, it does not explicitly fill the Claude parity section or list specific risks/follow-up work as optional template items.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/recovery-reviewer-contracts
📝 Coding Plan
  • Generate coding plan for human review comments

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

Migrating from UI to YAML configuration.

Use the @coderabbitai configuration command in a PR comment to get a dump of all your UI settings in YAML format. You can then edit this YAML file and upload it to the root of your repository to configure CodeRabbit programmatically.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 27 files


Since this is your first cubic review, here's how it works:

  • cubic automatically reviews your code and comments on bugs and improvements
  • Teach cubic by replying to its comments. cubic learns from your replies and gets better over time
  • Add one-off context when rerunning by tagging @cubic-dev-ai with guidance or docs links (including llms.txt)
  • Ask questions if you need clarification on any suggestion

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 7 issues, and left some high level feedback:

  • The appendWithinBudget helper is duplicated in both session-continuity.ts and startup-memory.ts; consider centralizing this in a shared utility to avoid divergence in future tweaks to the budget behavior.
  • In compileStartupMemory, the scopeTopicRefs.length > 0 guard already guarantees a first topic ref, so the if (!firstTopicRef) early-return branch is effectively dead code and could be simplified away to make the topic section logic easier to follow.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The `appendWithinBudget` helper is duplicated in both `session-continuity.ts` and `startup-memory.ts`; consider centralizing this in a shared utility to avoid divergence in future tweaks to the budget behavior.
- In `compileStartupMemory`, the `scopeTopicRefs.length > 0` guard already guarantees a first topic ref, so the `if (!firstTopicRef)` early-return branch is effectively dead code and could be simplified away to make the topic section logic easier to follow.

## Individual Comments

### Comment 1
<location path="src/lib/domain/startup-memory.ts" line_range="90-95" />
<code_context>
   ).flat();

   if (scopeTopicRefs.length > 0) {
-    appendBlock([
+    const [firstTopicRef, ...remainingTopicRefs] = scopeTopicRefs;
+    if (!firstTopicRef) {
+      const finalText = lines.join("\n").trimEnd();
+      const finalLines = finalText ? finalText.split("\n") : [];
+      return {
+        text: `${finalText}\n`,
+        lineCount: finalLines.length,
</code_context>
<issue_to_address>
**suggestion:** The `!firstTopicRef` branch after checking `scopeTopicRefs.length > 0` appears unreachable.

Within the `if (scopeTopicRefs.length > 0)` block you destructure `[firstTopicRef, ...remainingTopicRefs]`, so `firstTopicRef` is guaranteed to be defined. The subsequent `if (!firstTopicRef)` early return is therefore dead code. Consider removing that branch or the outer length check to simplify the control flow and avoid duplicate finalization logic.
</issue_to_address>

### Comment 2
<location path="src/lib/extractor/command-utils.ts" line_range="7-11" />
<code_context>
 const commandFailurePattern =
-  /(Process exited with code [1-9]\d*|\bexit(?:ed)? code [1-9]\d*\b|\b(?:error|errors|failed|failure|exception|traceback|assertionerror|not ok|ELIFECYCLE)\b|\bFAIL\b|command not found|No such file or directory)/iu;
+  /(Process exited with code [1-9]\d*|\bexit(?:ed)? code [1-9]\d*\b|\b[1-9]\d*\s+errors?\b|\b(?:error|failed|failure|exception|traceback|assertionerror|not ok|ELIFECYCLE)\b|\bFAIL\b|command not found|No such file or directory)/iu;
+const explicitSuccessExitCodePattern = /(Process exited with code 0|\bexit(?:ed)? code 0\b)/iu;
+const explicitFailureExitCodePattern = /(Process exited with code [1-9]\d*|\bexit(?:ed)? code [1-9]\d*\b)/iu;

</code_context>
<issue_to_address>
**issue (bug_risk):** Ordering of explicit success vs. failure heuristics may misclassify mixed outputs.

Given the current order (explicit failure exit codes → explicit success exit codes → `commandFailurePattern``commandSuccessPattern`), an output like `"exit code 0, but 1 error"` would be treated as success because `explicitSuccessExitCodePattern` matches before the `\b[1-9]\d*\s+errors?\b` part of `commandFailurePattern`. If such mixed outputs are realistic, consider checking failure patterns (or a specific guard for this combination) before treating `exit code 0` as definitive success.
</issue_to_address>

### Comment 3
<location path="test/command-utils.test.ts" line_range="83-92" />
<code_context>
     ).toBe(true);
   });

+  it("prefers failure when output contains both pass and fail markers", () => {
+    const toolCall = {
+      name: "exec_command",
+      arguments: JSON.stringify({ cmd: "pnpm test" }),
+      output: ["PASS src/a.test.ts", "FAIL src/b.test.ts", "Process exited with code 1"].join("\n")
+    };
+
+    expect(commandSucceeded(toolCall)).toBe(false);
+    expect(commandFailed(toolCall)).toBe(true);
+  });
+
+  it("prefers explicit success exit codes over incidental fail words in stdout", () => {
+    const toolCall = {
+      name: "exec_command",
+      arguments: JSON.stringify({ cmd: "pnpm docs:lint" }),
+      output: ["Process exited with code 0", "Output:", "Use PASS/FAIL reporting in docs."].join("\n")
+    };
+
+    expect(commandSucceeded(toolCall)).toBe(true);
+    expect(commandFailed(toolCall)).toBe(false);
+  });
+
</code_context>
<issue_to_address>
**suggestion (testing):** Consider adding a test for explicit success exit codes combined with generic error wording

Current tests cover precedence between PASS/FAIL markers and exit codes, including the ambiguous "PASS/FAIL" wording. One remaining gap is when stdout has an explicit success exit code (0) but also a generic error word like "error" or "failed" outside an exit-code line. Since the failure pattern still matches `error|failed|failure`, an extra case such as `"Process exited with code 0\nSome error text here"` would help ensure `explicitSuccessExitCodePattern` continues to override incidental error wording as intended.
</issue_to_address>

### Comment 4
<location path="test/sync-service.test.ts" line_range="578-587" />
<code_context>
+  it("clears a matching recovery marker when an already-processed rollout is retried", async () => {
</code_context>
<issue_to_address>
**suggestion (testing):** Add a complementary test for non-matching recovery markers to ensure they are not treated as recovery

You already test the positive recovery-marker cases well. To fully cover the contract, we also need the negative case: when a recovery record exists but does *not* match the current rollout/session (e.g., different `sessionId` or `rolloutPath`), `syncRollout` should leave that record intact and `isRecovery` should remain false. Please add a test that writes a non-matching recovery record, runs `syncRollout`, and asserts that (1) the recovery record is still present and (2) the resulting audit entry has `isRecovery !== true` to lock in this behavior.

Suggested implementation:

```typescript
    const result = await service.syncRollout(rolloutPath, true);

    expect(result.skipped).toBe(false);
    expect(await service.memoryStore.readSyncRecoveryRecord()).toBeNull();
  });

  it("does not treat non-matching recovery markers as recovery and leaves them intact", async () => {
    const projectDir = await tempDir("cam-sync-non-matching-recovery-project-");
    const memoryRoot = await tempDir("cam-sync-non-matching-recovery-memory-");
    const rolloutPath = path.join(projectDir, "rollout.jsonl");
    await fs.writeFile(rolloutPath, rolloutFixture(projectDir, "session-skip-recovery"), "utf8");

    const service = new SyncService(
      detectProjectContext(projectDir),
      baseConfig(memoryRoot),
      path.resolve("schemas/memory-operations.schema.json")
    );

    // write a recovery record that does NOT match the upcoming rollout/session
    await service.memoryStore.writeSyncRecoveryRecord({
      sessionId: "some-other-session-id",
      rolloutPath: "/different/project/rollout.jsonl",
      createdAt: new Date().toISOString(),
    });

    const result = await service.syncRollout(rolloutPath, true);

    // rollout should proceed normally (not skipped, not treated as recovery)
    expect(result.skipped).toBe(false);

    // the non-matching recovery record should remain
    const recoveryRecord = await service.memoryStore.readSyncRecoveryRecord();
    expect(recoveryRecord).not.toBeNull();
    expect(recoveryRecord?.sessionId).toBe("some-other-session-id");

    // and the resulting audit entry should not be marked as recovery
    const auditEntries = await service.memoryStore.readAuditEntries();
    expect(auditEntries[0]?.isRecovery).not.toBe(true);
  });

  it("clears a matching recovery marker when an already-processed rollout is retried", async () => {
    const projectDir = await tempDir("cam-sync-skip-recovery-project-");
    const memoryRoot = await tempDir("cam-sync-skip-recovery-memory-");
    const rolloutPath = path.join(projectDir, "rollout.jsonl");
    await fs.writeFile(rolloutPath, rolloutFixture(projectDir, "session-skip-recovery"), "utf8");

    const service = new SyncService(
      detectProjectContext(projectDir),
      baseConfig(memoryRoot),
      path.resolve("schemas/memory-operations.schema.json")
    );

```

The new test assumes the following helpers/APIs exist and match your existing tests:
1. `service.memoryStore.writeSyncRecoveryRecord({ sessionId, rolloutPath, createdAt })` – mirror the shape used in your "clears a matching recovery marker" test; adjust field names or arguments if your recovery record type differs.
2. `service.memoryStore.readAuditEntries()` returning an array of audit entries with an `isRecovery` boolean flag; if your API is named differently (e.g. `readAuditLog`, `listAuditEntries`, or nests `isRecovery` deeper), update the call and assertion accordingly.
3. If your audit log for `syncRollout` appends multiple entries, you may want to assert against the last entry instead of `[0]` (e.g. `auditEntries[auditEntries.length - 1]`).
4. If your existing tests use a different rollout fixture for this scenario, align `"session-skip-recovery"` with the appropriate fixture name so that the rollout is considered “already processed” in the same way as the matching-marker test.
</issue_to_address>

### Comment 5
<location path="test/session-continuity.test.ts" line_range="922-945" />
<code_context>
     expect(sanitized.project.confirmedWorking.join("\n")).not.toContain("12345678901234567890");
   });

+  it("applySessionContinuityLayerSummary clears stale goals when the next layer leaves goal empty", () => {
+    const base = {
+      ...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"),
+      goal: "Stale local goal",
+      incompleteNext: ["Carry over the old next step"]
+    };
+
+    const merged = applySessionContinuityLayerSummary(
+      base,
+      {
+        goal: "",
+        confirmedWorking: [],
+        triedAndFailed: [],
+        notYetTried: [],
+        incompleteNext: ["Fresh next step"],
+        filesDecisionsEnvironment: []
+      },
+      "session-clear-goal"
+    );
+
+    expect(merged.goal).toBe("");
+    expect(merged.incompleteNext).toContain("Fresh next step");
+    expect(merged.incompleteNext).toContain("Carry over the old next step");
+  });
+
</code_context>
<issue_to_address>
**suggestion (testing):** Add a test distinguishing between omitted `goal` and explicitly empty `goal` in layer summaries

Given the new `goalProvided` logic in `buildLayerSummary`, please add a complementary test where the next-layer summary omits `goal` entirely. That test should verify that the existing goal is preserved when `goal` is not present, contrasting with the current case where `goal: ""` clears it. This will help catch regressions if the summary-building logic is refactored later.

```suggestion
  it("applySessionContinuityLayerSummary clears stale goals when the next layer leaves goal empty", () => {
    const base = {
      ...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"),
      goal: "Stale local goal",
      incompleteNext: ["Carry over the old next step"]
    };

    const merged = applySessionContinuityLayerSummary(
      base,
      {
        goal: "",
        confirmedWorking: [],
        triedAndFailed: [],
        notYetTried: [],
        incompleteNext: ["Fresh next step"],
        filesDecisionsEnvironment: []
      },
      "session-clear-goal"
    );

    expect(merged.goal).toBe("");
    expect(merged.incompleteNext).toContain("Fresh next step");
    expect(merged.incompleteNext).toContain("Carry over the old next step");
  });

  it("applySessionContinuityLayerSummary preserves existing goal when the next layer omits goal", () => {
    const base = {
      ...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"),
      goal: "Stale local goal",
      incompleteNext: ["Carry over the old next step"]
    };

    const merged = applySessionContinuityLayerSummary(
      base,
      {
        // goal intentionally omitted to represent "not provided" in this layer
        confirmedWorking: [],
        triedAndFailed: [],
        notYetTried: [],
        incompleteNext: ["Fresh next step"],
        filesDecisionsEnvironment: []
      },
      "session-preserve-goal-when-omitted"
    );

    expect(merged.goal).toBe("Stale local goal");
    expect(merged.incompleteNext).toContain("Fresh next step");
    expect(merged.incompleteNext).toContain("Carry over the old next step");
  });
```
</issue_to_address>

### Comment 6
<location path="test/memory-store.test.ts" line_range="123-126" />
<code_context>
   });

-  it("does not report a memory file as startup-loaded when only header lines fit", async () => {
+  it("skips partial scope blocks when the startup budget cannot fit quoted lines", async () => {
     const projectDir = await tempDir("cam-store-startup-header-only-");
     const memoryRoot = await tempDir("cam-store-startup-header-only-mem-");
</code_context>
<issue_to_address>
**suggestion (testing):** Also assert that the startup preamble is still present under tiny budgets

To better cover `appendWithinBudget` in `compileStartupMemory`, add an assertion that the returned text still contains part of the static preamble (for example `# Codex Auto Memory`) even under very small budgets. That guards against regressions where we return an empty or malformed startup block.

Suggested implementation:

```typescript
  it("skips partial scope blocks when the startup budget cannot fit quoted lines", async () => {
    const projectDir = await tempDir("cam-store-startup-header-only-");
    const memoryRoot = await tempDir("cam-store-startup-header-only-mem-");
    const config: AppConfig = {

    const startup = await compileStartupMemory(store, 8);

    expect(startup.lineCount).toBeLessThanOrEqual(8);
    // Even under very small budgets, the static startup preamble should still be present.
    expect(startup.text).toContain("# Codex Auto Memory");
    expect(startup.text).not.toContain("## Project Local");
    expect(startup.text).not.toContain("| # Project Local Memory");
    expect(startup.sourceFiles).toEqual([]);

```

If the actual static preamble string in `compileStartupMemory` differs (for example it includes trailing spaces, different capitalization, or additional prefix text), update `"# Codex Auto Memory"` in the new expectation to match the exact literal used in the startup preamble so the test remains robust.
</issue_to_address>

### Comment 7
<location path="docs/progress-log.md" line_range="237" />
<code_context>
+
+- Added explicit `isRecovery` provenance to durable sync audit entries so reviewer surfaces no longer need to infer recovery follow-through indirectly.
+- Chinese next-step extraction now skips very short captures plus several narrative-connector fragments before they can land in continuity evidence.
+- Official Codex and Claude public docs were re-checked again before refreshing migration and reviewer wording, keeping the repository companion-first and supportable.
+
+### Post-alpha.22 review hardening
</code_context>
<issue_to_address>
**nitpick (typo):** Consider removing the redundancy in "re-checked again".

"Re-checked" already implies "again". Consider using either "were checked again" or "were re-checked" for cleaner wording.

```suggestion
- Official Codex and Claude public docs were re-checked before refreshing migration and reviewer wording, keeping the repository companion-first and supportable.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines 90 to +95
if (scopeTopicRefs.length > 0) {
appendBlock([
const [firstTopicRef, ...remainingTopicRefs] = scopeTopicRefs;
if (!firstTopicRef) {
const finalText = lines.join("\n").trimEnd();
const finalLines = finalText ? finalText.split("\n") : [];
return {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: The !firstTopicRef branch after checking scopeTopicRefs.length > 0 appears unreachable.

Within the if (scopeTopicRefs.length > 0) block you destructure [firstTopicRef, ...remainingTopicRefs], so firstTopicRef is guaranteed to be defined. The subsequent if (!firstTopicRef) early return is therefore dead code. Consider removing that branch or the outer length check to simplify the control flow and avoid duplicate finalization logic.

Comment on lines +7 to 11
const explicitSuccessExitCodePattern = /(Process exited with code 0|\bexit(?:ed)? code 0\b)/iu;
const explicitFailureExitCodePattern = /(Process exited with code [1-9]\d*|\bexit(?:ed)? code [1-9]\d*\b)/iu;

export function extractCommand(toolCall: RolloutToolCall): string | null {
try {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Ordering of explicit success vs. failure heuristics may misclassify mixed outputs.

Given the current order (explicit failure exit codes → explicit success exit codes → commandFailurePatterncommandSuccessPattern), an output like "exit code 0, but 1 error" would be treated as success because explicitSuccessExitCodePattern matches before the \b[1-9]\d*\s+errors?\b part of commandFailurePattern. If such mixed outputs are realistic, consider checking failure patterns (or a specific guard for this combination) before treating exit code 0 as definitive success.

Comment on lines +83 to +92
it("prefers failure when output contains both pass and fail markers", () => {
const toolCall = {
name: "exec_command",
arguments: JSON.stringify({ cmd: "pnpm test" }),
output: ["PASS src/a.test.ts", "FAIL src/b.test.ts", "Process exited with code 1"].join("\n")
};

expect(commandSucceeded(toolCall)).toBe(false);
expect(commandFailed(toolCall)).toBe(true);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for explicit success exit codes combined with generic error wording

Current tests cover precedence between PASS/FAIL markers and exit codes, including the ambiguous "PASS/FAIL" wording. One remaining gap is when stdout has an explicit success exit code (0) but also a generic error word like "error" or "failed" outside an exit-code line. Since the failure pattern still matches error|failed|failure, an extra case such as "Process exited with code 0\nSome error text here" would help ensure explicitSuccessExitCodePattern continues to override incidental error wording as intended.

Comment on lines +578 to +587
it("clears a matching recovery marker when an already-processed rollout is retried", async () => {
const projectDir = await tempDir("cam-sync-skip-recovery-project-");
const memoryRoot = await tempDir("cam-sync-skip-recovery-memory-");
const rolloutPath = path.join(projectDir, "rollout.jsonl");
await fs.writeFile(rolloutPath, rolloutFixture(projectDir, "session-skip-recovery"), "utf8");

const service = new SyncService(
detectProjectContext(projectDir),
baseConfig(memoryRoot),
path.resolve("schemas/memory-operations.schema.json")
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a complementary test for non-matching recovery markers to ensure they are not treated as recovery

You already test the positive recovery-marker cases well. To fully cover the contract, we also need the negative case: when a recovery record exists but does not match the current rollout/session (e.g., different sessionId or rolloutPath), syncRollout should leave that record intact and isRecovery should remain false. Please add a test that writes a non-matching recovery record, runs syncRollout, and asserts that (1) the recovery record is still present and (2) the resulting audit entry has isRecovery !== true to lock in this behavior.

Suggested implementation:

    const result = await service.syncRollout(rolloutPath, true);

    expect(result.skipped).toBe(false);
    expect(await service.memoryStore.readSyncRecoveryRecord()).toBeNull();
  });

  it("does not treat non-matching recovery markers as recovery and leaves them intact", async () => {
    const projectDir = await tempDir("cam-sync-non-matching-recovery-project-");
    const memoryRoot = await tempDir("cam-sync-non-matching-recovery-memory-");
    const rolloutPath = path.join(projectDir, "rollout.jsonl");
    await fs.writeFile(rolloutPath, rolloutFixture(projectDir, "session-skip-recovery"), "utf8");

    const service = new SyncService(
      detectProjectContext(projectDir),
      baseConfig(memoryRoot),
      path.resolve("schemas/memory-operations.schema.json")
    );

    // write a recovery record that does NOT match the upcoming rollout/session
    await service.memoryStore.writeSyncRecoveryRecord({
      sessionId: "some-other-session-id",
      rolloutPath: "/different/project/rollout.jsonl",
      createdAt: new Date().toISOString(),
    });

    const result = await service.syncRollout(rolloutPath, true);

    // rollout should proceed normally (not skipped, not treated as recovery)
    expect(result.skipped).toBe(false);

    // the non-matching recovery record should remain
    const recoveryRecord = await service.memoryStore.readSyncRecoveryRecord();
    expect(recoveryRecord).not.toBeNull();
    expect(recoveryRecord?.sessionId).toBe("some-other-session-id");

    // and the resulting audit entry should not be marked as recovery
    const auditEntries = await service.memoryStore.readAuditEntries();
    expect(auditEntries[0]?.isRecovery).not.toBe(true);
  });

  it("clears a matching recovery marker when an already-processed rollout is retried", async () => {
    const projectDir = await tempDir("cam-sync-skip-recovery-project-");
    const memoryRoot = await tempDir("cam-sync-skip-recovery-memory-");
    const rolloutPath = path.join(projectDir, "rollout.jsonl");
    await fs.writeFile(rolloutPath, rolloutFixture(projectDir, "session-skip-recovery"), "utf8");

    const service = new SyncService(
      detectProjectContext(projectDir),
      baseConfig(memoryRoot),
      path.resolve("schemas/memory-operations.schema.json")
    );

The new test assumes the following helpers/APIs exist and match your existing tests:

  1. service.memoryStore.writeSyncRecoveryRecord({ sessionId, rolloutPath, createdAt }) – mirror the shape used in your "clears a matching recovery marker" test; adjust field names or arguments if your recovery record type differs.
  2. service.memoryStore.readAuditEntries() returning an array of audit entries with an isRecovery boolean flag; if your API is named differently (e.g. readAuditLog, listAuditEntries, or nests isRecovery deeper), update the call and assertion accordingly.
  3. If your audit log for syncRollout appends multiple entries, you may want to assert against the last entry instead of [0] (e.g. auditEntries[auditEntries.length - 1]).
  4. If your existing tests use a different rollout fixture for this scenario, align "session-skip-recovery" with the appropriate fixture name so that the rollout is considered “already processed” in the same way as the matching-marker test.

Comment on lines +922 to +945
it("applySessionContinuityLayerSummary clears stale goals when the next layer leaves goal empty", () => {
const base = {
...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"),
goal: "Stale local goal",
incompleteNext: ["Carry over the old next step"]
};

const merged = applySessionContinuityLayerSummary(
base,
{
goal: "",
confirmedWorking: [],
triedAndFailed: [],
notYetTried: [],
incompleteNext: ["Fresh next step"],
filesDecisionsEnvironment: []
},
"session-clear-goal"
);

expect(merged.goal).toBe("");
expect(merged.incompleteNext).toContain("Fresh next step");
expect(merged.incompleteNext).toContain("Carry over the old next step");
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test distinguishing between omitted goal and explicitly empty goal in layer summaries

Given the new goalProvided logic in buildLayerSummary, please add a complementary test where the next-layer summary omits goal entirely. That test should verify that the existing goal is preserved when goal is not present, contrasting with the current case where goal: "" clears it. This will help catch regressions if the summary-building logic is refactored later.

Suggested change
it("applySessionContinuityLayerSummary clears stale goals when the next layer leaves goal empty", () => {
const base = {
...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"),
goal: "Stale local goal",
incompleteNext: ["Carry over the old next step"]
};
const merged = applySessionContinuityLayerSummary(
base,
{
goal: "",
confirmedWorking: [],
triedAndFailed: [],
notYetTried: [],
incompleteNext: ["Fresh next step"],
filesDecisionsEnvironment: []
},
"session-clear-goal"
);
expect(merged.goal).toBe("");
expect(merged.incompleteNext).toContain("Fresh next step");
expect(merged.incompleteNext).toContain("Carry over the old next step");
});
it("applySessionContinuityLayerSummary clears stale goals when the next layer leaves goal empty", () => {
const base = {
...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"),
goal: "Stale local goal",
incompleteNext: ["Carry over the old next step"]
};
const merged = applySessionContinuityLayerSummary(
base,
{
goal: "",
confirmedWorking: [],
triedAndFailed: [],
notYetTried: [],
incompleteNext: ["Fresh next step"],
filesDecisionsEnvironment: []
},
"session-clear-goal"
);
expect(merged.goal).toBe("");
expect(merged.incompleteNext).toContain("Fresh next step");
expect(merged.incompleteNext).toContain("Carry over the old next step");
});
it("applySessionContinuityLayerSummary preserves existing goal when the next layer omits goal", () => {
const base = {
...createEmptySessionContinuityState("project-local", "project-1", "worktree-1"),
goal: "Stale local goal",
incompleteNext: ["Carry over the old next step"]
};
const merged = applySessionContinuityLayerSummary(
base,
{
// goal intentionally omitted to represent "not provided" in this layer
confirmedWorking: [],
triedAndFailed: [],
notYetTried: [],
incompleteNext: ["Fresh next step"],
filesDecisionsEnvironment: []
},
"session-preserve-goal-when-omitted"
);
expect(merged.goal).toBe("Stale local goal");
expect(merged.incompleteNext).toContain("Fresh next step");
expect(merged.incompleteNext).toContain("Carry over the old next step");
});

Comment on lines +123 to 126
it("skips partial scope blocks when the startup budget cannot fit quoted lines", async () => {
const projectDir = await tempDir("cam-store-startup-header-only-");
const memoryRoot = await tempDir("cam-store-startup-header-only-mem-");
const config: AppConfig = {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Also assert that the startup preamble is still present under tiny budgets

To better cover appendWithinBudget in compileStartupMemory, add an assertion that the returned text still contains part of the static preamble (for example # Codex Auto Memory) even under very small budgets. That guards against regressions where we return an empty or malformed startup block.

Suggested implementation:

  it("skips partial scope blocks when the startup budget cannot fit quoted lines", async () => {
    const projectDir = await tempDir("cam-store-startup-header-only-");
    const memoryRoot = await tempDir("cam-store-startup-header-only-mem-");
    const config: AppConfig = {

    const startup = await compileStartupMemory(store, 8);

    expect(startup.lineCount).toBeLessThanOrEqual(8);
    // Even under very small budgets, the static startup preamble should still be present.
    expect(startup.text).toContain("# Codex Auto Memory");
    expect(startup.text).not.toContain("## Project Local");
    expect(startup.text).not.toContain("| # Project Local Memory");
    expect(startup.sourceFiles).toEqual([]);

If the actual static preamble string in compileStartupMemory differs (for example it includes trailing spaces, different capitalization, or additional prefix text), update "# Codex Auto Memory" in the new expectation to match the exact literal used in the startup preamble so the test remains robust.


- Added explicit `isRecovery` provenance to durable sync audit entries so reviewer surfaces no longer need to infer recovery follow-through indirectly.
- Chinese next-step extraction now skips very short captures plus several narrative-connector fragments before they can land in continuity evidence.
- Official Codex and Claude public docs were re-checked again before refreshing migration and reviewer wording, keeping the repository companion-first and supportable.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick (typo): Consider removing the redundancy in "re-checked again".

"Re-checked" already implies "again". Consider using either "were checked again" or "were re-checked" for cleaner wording.

Suggested change
- Official Codex and Claude public docs were re-checked again before refreshing migration and reviewer wording, keeping the repository companion-first and supportable.
- Official Codex and Claude public docs were re-checked before refreshing migration and reviewer wording, keeping the repository companion-first and supportable.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/lib/domain/session-continuity.ts (1)

87-107: Consider extracting shared appendWithinBudget helper.

This function is duplicated verbatim in startup-memory.ts (lines 27-47). Consider extracting it to a shared utility module (e.g., src/lib/util/text.ts or a new src/lib/util/budget.ts) to eliminate duplication.

♻️ Suggested extraction
// src/lib/util/budget.ts
export function appendWithinBudget(
  lines: string[],
  blockLines: string[],
  maxLines: number,
  minimumLines = 1
): number {
  if (maxLines - lines.length < minimumLines) {
    return 0;
  }

  let appended = 0;
  for (const line of blockLines) {
    if (lines.length >= maxLines) {
      break;
    }
    lines.push(line);
    appended += 1;
  }

  return appended;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/domain/session-continuity.ts` around lines 87 - 107, The
appendWithinBudget function is duplicated in session-continuity.ts and
startup-memory.ts; extract it to a single shared utility (e.g., new module
exporting appendWithinBudget) and replace the local copies with imports. Create
a new util module that exports function appendWithinBudget(lines, blockLines,
maxLines, minimumLines = 1) and update both session-continuity.ts and
startup-memory.ts to import and call that exported function instead of
maintaining their own copies.
src/lib/domain/startup-memory.ts (1)

27-47: Duplicate of appendWithinBudget in session-continuity.ts.

As noted in the review of session-continuity.ts, this function is duplicated. Consider extracting to a shared utility.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/domain/startup-memory.ts` around lines 27 - 47, Duplicate function
appendWithinBudget exists here and in session-continuity.ts; extract it to a
shared utility module (e.g., create a new helper file exporting
appendWithinBudget), move the implementation there, and replace the local
implementations in startup-memory.ts and session-continuity.ts with an import of
the shared function; ensure the function is exported (named export) and both
files import that symbol and remove the duplicated code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/lib/domain/memory-store.ts`:
- Around line 530-534: The current try/catch around
readJsonFile(this.paths.stateFile) swallows all errors and treats them as "empty
state"; change it so only expected benign conditions (file-not-found or JSON
parse errors) map to normalizeSyncState(null) while other IO errors are
propagated. Update the block around readJsonFile/normalizeSyncState: await
readJsonFile<SyncState>(this.paths.stateFile) inside try, catch the thrown error
and inspect it (e.g., if (err.code === 'ENOENT') return
normalizeSyncState(null); if (err instanceof SyntaxError || error message
indicates JSON parse) return normalizeSyncState(null); else throw err). This
preserves existing behavior for missing/malformed state but surfaces real
filesystem errors instead of hiding them.

In `@src/lib/domain/sync-service.ts`:
- Around line 87-93: The recovery marker is being cleared via
clearSyncRecoveryRecordBestEffort({ rolloutPath, sessionId: evidence.sessionId
}) before store.appendSyncAuditEntry(...) so a failed append can leave the
marker incorrectly removed; change the ordering so you first await
this.store.appendSyncAuditEntry(...) and only after that succeeds call
clearSyncRecoveryRecordBestEffort with the same rolloutPath and sessionId,
ensuring any thrown error from append prevents removal of the recovery marker
(i.e., move the clearSyncRecoveryRecordBestEffort call to after the append and
do not swallow append errors).

---

Nitpick comments:
In `@src/lib/domain/session-continuity.ts`:
- Around line 87-107: The appendWithinBudget function is duplicated in
session-continuity.ts and startup-memory.ts; extract it to a single shared
utility (e.g., new module exporting appendWithinBudget) and replace the local
copies with imports. Create a new util module that exports function
appendWithinBudget(lines, blockLines, maxLines, minimumLines = 1) and update
both session-continuity.ts and startup-memory.ts to import and call that
exported function instead of maintaining their own copies.

In `@src/lib/domain/startup-memory.ts`:
- Around line 27-47: Duplicate function appendWithinBudget exists here and in
session-continuity.ts; extract it to a shared utility module (e.g., create a new
helper file exporting appendWithinBudget), move the implementation there, and
replace the local implementations in startup-memory.ts and session-continuity.ts
with an import of the shared function; ensure the function is exported (named
export) and both files import that symbol and remove the duplicated code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7a4924c5-9b11-4a67-bdf2-e9541eb6fd04

📥 Commits

Reviewing files that changed from the base of the PR and between 29dc59c and 7aa24a9.

📒 Files selected for processing (27)
  • .gitignore
  • AGENTS.md
  • CHANGELOG.md
  • docs/claude-reference.md
  • docs/native-migration.en.md
  • docs/native-migration.md
  • docs/next-phase-brief.md
  • docs/progress-log.md
  • docs/reviewer-handoff.md
  • src/lib/domain/memory-store.ts
  • src/lib/domain/memory-sync-audit.ts
  • src/lib/domain/recovery-records.ts
  • src/lib/domain/session-continuity.ts
  • src/lib/domain/startup-memory.ts
  • src/lib/domain/sync-service.ts
  • src/lib/extractor/command-utils.ts
  • src/lib/extractor/session-continuity-evidence.ts
  • src/lib/extractor/session-continuity-summarizer.ts
  • src/lib/types.ts
  • test/audit.test.ts
  • test/command-utils.test.ts
  • test/memory-command.test.ts
  • test/memory-store.test.ts
  • test/project-context.test.ts
  • test/session-command.test.ts
  • test/session-continuity.test.ts
  • test/sync-service.test.ts

Comment on lines +530 to +534
try {
return normalizeSyncState(await readJsonFile<SyncState>(this.paths.stateFile));
} catch {
return normalizeSyncState(null);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't collapse every state-file read error into "empty state".

Because src/lib/util/fs.ts's readJsonFile() also throws on fs.readFile(), Lines 530-534 now suppress storage failures like EACCES/EIO the same way as malformed JSON. That can make sync treat already-processed rollouts as new instead of surfacing the real failure.

Suggested narrowing
  public async getSyncState(): Promise<Required<SyncState>> {
    try {
      return normalizeSyncState(await readJsonFile<SyncState>(this.paths.stateFile));
-    } catch {
-      return normalizeSyncState(null);
+    } catch (error) {
+      const errno = error as NodeJS.ErrnoException;
+      if (error instanceof SyntaxError || errno.code === "ENOENT") {
+        return normalizeSyncState(null);
+      }
+      throw error;
     }
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/domain/memory-store.ts` around lines 530 - 534, The current try/catch
around readJsonFile(this.paths.stateFile) swallows all errors and treats them as
"empty state"; change it so only expected benign conditions (file-not-found or
JSON parse errors) map to normalizeSyncState(null) while other IO errors are
propagated. Update the block around readJsonFile/normalizeSyncState: await
readJsonFile<SyncState>(this.paths.stateFile) inside try, catch the thrown error
and inspect it (e.g., if (err.code === 'ENOENT') return
normalizeSyncState(null); if (err instanceof SyntaxError || error message
indicates JSON parse) return normalizeSyncState(null); else throw err). This
preserves existing behavior for missing/malformed state but surfaces real
filesystem errors instead of hiding them.

Comment on lines +87 to 93
if (isRecovery) {
await this.clearSyncRecoveryRecordBestEffort({
rolloutPath,
sessionId: evidence.sessionId
});
}
await this.store.appendSyncAuditEntry(
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Defer recovery-marker cleanup until after skipped-audit append succeeds.

At Line 87, the matching recovery marker is cleared before the skipped audit write. If appendSyncAuditEntry fails at Line 93, the marker is lost even though the retry path did not complete cleanly.

💡 Proposed ordering fix
     if (!force && (await this.store.hasProcessedRollout(processedIdentity))) {
-      if (isRecovery) {
-        await this.clearSyncRecoveryRecordBestEffort({
-          rolloutPath,
-          sessionId: evidence.sessionId
-        });
-      }
       await this.store.appendSyncAuditEntry(
         buildMemorySyncAuditEntry({
           project: this.project,
           config: this.config,
@@
           ...(isRecovery ? { isRecovery: true } : {})
         })
       );
+      if (isRecovery) {
+        await this.clearSyncRecoveryRecordBestEffort({
+          rolloutPath,
+          sessionId: evidence.sessionId
+        });
+      }
       return {
         applied: [],
         skipped: true,
         message: `Skipped ${rolloutPath}; it was already processed.`
       };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/lib/domain/sync-service.ts` around lines 87 - 93, The recovery marker is
being cleared via clearSyncRecoveryRecordBestEffort({ rolloutPath, sessionId:
evidence.sessionId }) before store.appendSyncAuditEntry(...) so a failed append
can leave the marker incorrectly removed; change the ordering so you first await
this.store.appendSyncAuditEntry(...) and only after that succeeds call
clearSyncRecoveryRecordBestEffort with the same rolloutPath and sessionId,
ensuring any thrown error from append prevents removal of the recovery marker
(i.e., move the clearSyncRecoveryRecordBestEffort call to after the append and
do not swallow append errors).

Comment on lines +69 to +72
const captured = normalizeMessage(match[1]);
if (captured.length < 10) continue;
if (/^(?:而是|但是|因为|所以|不过|然后|其实|就是|也就是说)/u.test(captured)) continue;
matches.push(captured);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The new 10-character cutoff is too blunt.

Line 70 now drops every short capture, not just the Chinese connector fragments from Line 71. That strips valid next steps like run tests, fix lint, or 重跑测试, so continuity can lose the most actionable handoff item.

Scope the length guard to the CJK false-positive case
       const captured = normalizeMessage(match[1]);
-      if (captured.length < 10) continue;
-      if (/^(?:而是|但是|因为|所以|不过|然后|其实|就是|也就是说)/u.test(captured)) continue;
+      if (/^(?:而是|但是|因为|所以|不过|然后|其实|就是|也就是说)/u.test(captured)) {
+        continue;
+      }
+      if (/[\u4E00-\u9FFF]/u.test(captured) && captured.length < 6) {
+        continue;
+      }
       matches.push(captured);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const captured = normalizeMessage(match[1]);
if (captured.length < 10) continue;
if (/^(?:||||||||)/u.test(captured)) continue;
matches.push(captured);
const captured = normalizeMessage(match[1]);
if (/^(?:||||||||)/u.test(captured)) {
continue;
}
if (/[\u4E00-\u9FFF]/u.test(captured) && captured.length < 6) {
continue;
}
matches.push(captured);

@Boulea7 Boulea7 merged commit baed196 into main Mar 18, 2026
5 checks passed
@Boulea7 Boulea7 deleted the fix/recovery-reviewer-contracts branch March 18, 2026 16:09
Boulea7 added a commit that referenced this pull request Mar 18, 2026
fix: harden sync recovery and reviewer contracts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant