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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/loop-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ interface LoopState {
modelFailed?: boolean // Whether model error occurred
sandbox?: boolean // Whether using Docker sandbox
sandboxContainer?: string // Container name if sandboxed
completionSummary?: string // Summary of loop completion
executionModel?: string // Model used for execution
auditorModel?: string // Model used for auditing
workspaceId?: string // OpenCode workspace ID
Expand Down Expand Up @@ -202,6 +201,7 @@ In user-facing language, a plan is decomposed into **milestones** — ordered un
- `currentSectionIndex` / `totalSections` columns on the loop row
- `<!-- forge-section -->` markers in the architect plan output
- `section-read` tool reads the current or specified milestone
- Completed milestone summaries live on `section_plans.summary_done`, `summary_deviations`, and `summary_follow_ups`, then render into worktree completion logs under `### Sections`

Decomposition is a one-shot preprocessing step at loop start (`services/deterministic-decomposer.ts`), not a runtime loop phase. Once milestones exist, the loop advances through them via `advance-section` transitions inside the `auditing` phase. When the `final_auditing` phase reports outstanding bug findings, the loop rotates to a coding session in "final-audit fix" mode — the code agent fixes the reported findings without rewinding to a specific section, and on idle the loop transitions straight back to `final_auditing` for re-verification.

Expand Down
6 changes: 5 additions & 1 deletion src/hooks/host-side-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import type { LoopState, TerminationReason } from '../loop'
import type { Logger, PluginConfig } from '../types'
import type { createSandboxManager } from '../sandbox/manager'
import { terminationReasonToString } from '../loop'
import { buildWorktreeCompletionPayload, writeWorktreeCompletionLog } from '../services/worktree-log'
import { buildWorktreeCompletionPayload, writeWorktreeCompletionLog, isWorktreeLoggingEnabled } from '../services/worktree-log'
import type { PendingTeardownRegistry } from '../workspace/pending-teardown'
import type { LoopsRepo } from '../storage/repos/loops-repo'
import type { LoopSessionUsageRepo } from '../storage/repos/loop-session-usage-repo'
import { aggregateToUsageSummary } from '../utils/loop-format'
import { sweepStaleForgeWorkspaces } from '../workspace/sweep-stale'
import type { SectionDigestEntry } from '../loop/prompts'

export interface TerminationSideEffectsContext {
v2Client: OpencodeClient
Expand All @@ -17,6 +18,7 @@ export interface TerminationSideEffectsContext {
sandboxManager?: ReturnType<typeof createSandboxManager>
dataDir?: string
getPlanText?: (loopName: string, sessionId: string) => string | null
getSectionDigest?: (state: LoopState) => SectionDigestEntry[]
pendingTeardowns?: PendingTeardownRegistry
loopsRepo?: LoopsRepo
projectId?: string
Expand Down Expand Up @@ -60,6 +62,7 @@ function writeTerminationLog(
ctx: TerminationSideEffectsContext,
): void {
if (!state.worktree) return
if (!isWorktreeLoggingEnabled(ctx.getConfig())) return

const projectDir = state.projectDir ?? state.worktreeDir
const planText = ctx.getPlanText?.(state.loopName, state.sessionId)
Expand All @@ -81,6 +84,7 @@ function writeTerminationLog(
worktreeBranch: state.worktreeBranch,
dataDir: ctx.dataDir,
usage,
sections: ctx.getSectionDigest?.(state),
},
ctx.logger,
)
Expand Down
1 change: 1 addition & 0 deletions src/hooks/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function createLoopEventHandler(
sandboxManager,
dataDir,
getPlanText: loop.getPlanText,
getSectionDigest: loop.getCompletedSectionDigest,
pendingTeardowns,
loopsRepo,
projectId,
Expand Down
42 changes: 17 additions & 25 deletions src/loop/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { LoopState } from './state'
import type { ReviewFindingRow } from '../storage/repos/review-findings-repo'
import type { SectionPlanRow } from '../storage/repos/section-plans-repo'
import { SECTION_SUMMARY_START_MARKER, SECTION_SUMMARY_END_MARKER } from '../utils/section-summary'
import { formatSectionSummaries, type FormatSectionSummariesOptions } from '../utils/loop-format'

export interface SectionDigestEntry {
index: number
Expand All @@ -19,6 +20,17 @@ export interface PromptContext {
getCompletedSectionDigest(state: LoopState): SectionDigestEntry[]
}

const PROMPT_SECTION_SUMMARY_FORMAT: FormatSectionSummariesOptions = {
sectionHeadingLevel: 2,
labelStyle: 'heading',
labelHeadingLevel: 3,
}

function sectionSummariesBlock(digest: SectionDigestEntry[], heading: string): string {
if (digest.length === 0) return ''
return `\n\n### ${heading}\n${formatSectionSummaries(digest, PROMPT_SECTION_SUMMARY_FORMAT).join('\n')}`
}

function buildSandboxContextNoteFromFlag(sandbox: boolean): string {
if (!sandbox) return ''
return [
Expand All @@ -35,16 +47,6 @@ function buildSandboxContextNote(state: LoopState): string {
return buildSandboxContextNoteFromFlag(state.sandbox ?? false)
}

function formatSectionsSummary(digest: SectionDigestEntry[]): string {
return digest.map(s => {
let parts = `## Section ${s.index + 1}: ${s.title}`
if (s.summaryDone) parts += `\n### Done\n${s.summaryDone}`
if (s.summaryDeviations) parts += `\n### Deviations\n${s.summaryDeviations}`
if (s.summaryFollowUps) parts += `\n### Follow-ups\n${s.summaryFollowUps}`
return parts
}).join('\n\n')
}

export function buildContinuationPrompt(ctx: PromptContext, state: LoopState, auditFindings?: string): string {
if (state.totalSections > 0) {
return buildSectionContinuationPrompt(ctx, state, auditFindings || '')
Expand Down Expand Up @@ -139,9 +141,7 @@ export function buildSectionInitialPromptText(input: {
const digest = input.completedSectionDigest ?? []
let header = `[Loop section ${idx + 1}/${input.totalSections} -- iteration ${input.iteration}/${input.maxIterations}]`

if (digest.length > 0) {
header += `\n\n### Prior Sections' Summaries\n${formatSectionsSummary(digest)}`
}
header += sectionSummariesBlock(digest, "Prior Sections' Summaries")

header += `\n\n## Section plan\n${input.sectionContent}`

Expand All @@ -157,9 +157,7 @@ export function buildSectionAuditPrompt(ctx: PromptContext, state: LoopState): s
const digest = ctx.getCompletedSectionDigest(state)
let header = `[Loop section audit ${idx + 1}/${total}]`

if (digest.length > 0) {
header += `\n\n### Prior Sections' Summaries\n${formatSectionsSummary(digest)}`
}
header += sectionSummariesBlock(digest, "Prior Sections' Summaries")

header += `\n\n## Section under audit\n${section.content}`

Expand All @@ -179,9 +177,7 @@ export function buildSectionContinuationPrompt(ctx: PromptContext, state: LoopSt
const digest = ctx.getCompletedSectionDigest(state)
let header = `[Loop section ${idx + 1}/${total} -- iteration ${iter}/${maxIter} (continuation)]`

if (digest.length > 0) {
header += `\n\n### Prior Sections' Summaries\n${formatSectionsSummary(digest)}`
}
header += sectionSummariesBlock(digest, "Prior Sections' Summaries")

header += `\n\n## Section plan\n${section.content}`
header += `\n\n---\n## Auditor feedback from previous attempt\n${auditText}`
Expand All @@ -203,9 +199,7 @@ export function buildFinalAuditFixPrompt(ctx: PromptContext, state: LoopState, a
let header = `[Final-audit fix -- iteration ${state.iteration}/${state.maxIterations}]`
header += `\n\n## Master Plan\n${planText}`

if (digest.length > 0) {
header += `\n\n### Completed Sections' Summaries\n${formatSectionsSummary(digest)}`
}
header += sectionSummariesBlock(digest, "Completed Sections' Summaries")

header += `\n\n---\n## Final auditor feedback\n${auditText}`

Expand All @@ -227,9 +221,7 @@ export function buildFinalAuditPrompt(ctx: PromptContext, state: LoopState): str
let header = `[Final integration audit]`
header += `\n\n## Master Plan\n${planText}`

if (digest.length > 0) {
header += `\n\n### Completed Sections' Summaries\n${formatSectionsSummary(digest)}`
}
header += sectionSummariesBlock(digest, "Completed Sections' Summaries")

header += `\n\n---\nFinal audit instructions:\n- Verify the master plan's top-level Verification commands and acceptance criteria.\n- Use the per-section ### Deviations entries to interpret discrepancies. If a discrepancy is explained by a deviation, accept it unless it materially breaks the master plan's top-level Verification.\n- Write findings with sectionIndex pointing to the section you believe contains the bug. Use crossSection: true only when the bug spans multiple sections.\n- The loop terminates automatically when there are no outstanding bug-severity findings. Do not write findings unless they describe real, blocking issues.`

Expand Down
4 changes: 2 additions & 2 deletions src/loop/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export interface Loop {
setWorkspaceId(name: string, workspaceId: string): void
incrementError(name: string): number
resetError(name: string): void
terminateLoop(name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number; summary?: string }): void
terminateLoop(name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number }): void
getOutstandingFindings(loopName?: string, severity?: 'bug' | 'warning'): ReviewFindingRow[]
getStallTimeoutMs(): number
getMaxConsecutiveStalls(): number
Expand Down Expand Up @@ -2098,7 +2098,7 @@ export function createLoop(deps: LoopRuntimeDeps): Loop {
setWorkspaceId: (name: string, workspaceId: string) => loopService.setWorkspaceId(name, workspaceId),
incrementError: (name: string) => loopService.incrementError(name),
resetError: (name: string) => loopService.resetError(name),
terminateLoop: (name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number; summary?: string }) => loopService.terminate(name, opts),
terminateLoop: (name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number }) => loopService.terminate(name, opts),
getOutstandingFindings: (loopName?: string, severity?: 'bug' | 'warning') => loopService.getOutstandingFindings(loopName, severity),
getStallTimeoutMs: () => loopService.getStallTimeoutMs(),
getMaxConsecutiveStalls: () => loopService.getMaxConsecutiveStalls(),
Expand Down
6 changes: 2 additions & 4 deletions src/loop/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface LoopService {
setStatus(name: string, status: 'running' | 'completed' | 'cancelled' | 'errored' | 'stalled'): void
clearWorkspaceId(name: string): void
setWorkspaceId(name: string, workspaceId: string): void
terminate(name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number; summary?: string }): void
terminate(name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number }): void
replaceSession(name: string, opts: { newSessionId: string; phase: LoopState['phase']; iteration?: number; resetError?: boolean; auditCount?: number; lastAuditResult?: string | null }): void
getSectionPlan(state: LoopState, index: number): SectionPlanRow | null
getNextIncompleteSectionPlan(state: LoopState): SectionPlanRow | null
Expand Down Expand Up @@ -105,7 +105,6 @@ export function rowToLoopState(row: LoopRow, large: LoopLargeFields | null): Loo
modelFailed: row.modelFailed,
sandbox: row.sandbox,
sandboxContainer: row.sandboxContainer ?? undefined,
completionSummary: row.completionSummary ?? undefined,
executionModel: row.executionModel ?? undefined,
auditorModel: row.auditorModel ?? undefined,
workspaceId: row.workspaceId ?? undefined,
Expand Down Expand Up @@ -154,7 +153,6 @@ export function createLoopService(
startedAt: new Date(state.startedAt).getTime(),
completedAt: state.completedAt ? new Date(state.completedAt).getTime() : null,
terminationReason: state.terminationReason ?? null,
completionSummary: state.completionSummary ?? null,
workspaceId: state.workspaceId ?? null,
hostSessionId: state.hostSessionId ?? null,
currentSectionIndex: state.currentSectionIndex,
Expand Down Expand Up @@ -392,7 +390,7 @@ export function createLoopService(
notifyLoopChange('audit-result', name, state ? { projectDir: state.projectDir, worktreeDir: state.worktreeDir } : undefined)
}

function terminate(name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number; summary?: string }): void {
function terminate(name: string, opts: { status: 'completed' | 'cancelled' | 'errored' | 'stalled'; reason: string; completedAt: number }): void {
const state = getAnyState(name)
loopsRepo.terminate(projectId, name, opts)
notifyLoopChange('terminate', name, state ? { projectDir: state.projectDir, worktreeDir: state.worktreeDir } : undefined)
Expand Down
3 changes: 0 additions & 3 deletions src/loop/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ interface LoopStateBase {
modelFailed?: boolean
sandbox?: boolean
sandboxContainer?: string
completionSummary?: string
executionModel?: string
auditorModel?: string
workspaceId?: string
Expand Down Expand Up @@ -68,7 +67,6 @@ export function loopRowToState(row: LoopRow, large?: LoopLargeFields | null): Lo
modelFailed: row.modelFailed,
sandbox: row.sandbox,
sandboxContainer: row.sandboxContainer ?? undefined,
completionSummary: row.completionSummary ?? undefined,
executionModel: row.executionModel ?? undefined,
auditorModel: row.auditorModel ?? undefined,
workspaceId: row.workspaceId ?? undefined,
Expand Down Expand Up @@ -113,7 +111,6 @@ export function loopStateToRow(state: LoopState, projectId: string): Omit<LoopRo
startedAt: new Date(state.startedAt).getTime(),
completedAt: state.completedAt ? new Date(state.completedAt).getTime() : null,
terminationReason: state.terminationReason ?? null,
completionSummary: state.completionSummary ?? null,
workspaceId: state.workspaceId ?? null,
hostSessionId: state.hostSessionId ?? null,
currentSectionIndex: state.currentSectionIndex,
Expand Down
32 changes: 24 additions & 8 deletions src/services/worktree-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { homedir } from 'os'
import type { PluginConfig, Logger } from '../types'
import { toContainerPath } from '../sandbox/path'
import type { LoopUsageSummary } from '../loop/token-usage'
import { formatUsageSummary } from '../utils/loop-format'
import { formatUsageSummary, formatSectionSummaries } from '../utils/loop-format'
import type { SectionDigestEntry } from '../loop/prompts'

/**
* Context for resolving worktree log target paths.
Expand Down Expand Up @@ -41,6 +42,8 @@ export interface WorktreeCompletionLogPayload {
planText?: string | null
/** Cumulative usage summary for the loop, if available. */
usage?: LoopUsageSummary | null
/** Completed section digest entries, if available. */
sections?: SectionDigestEntry[] | null
}

/**
Expand All @@ -66,6 +69,16 @@ export interface WorktreeLogTarget {
permissionPath: string | null
}

/** Returns true when worktree completion logging is enabled in config. */
export function isWorktreeLoggingEnabled(config: PluginConfig): boolean {
return Boolean(config.loop?.worktreeLogging?.enabled)
}

/** Build an optional `### <heading>` markdown block, or '' when there are no lines. */
function optionalMarkdownSection(heading: string, lines: string[]): string {
return lines.length > 0 ? `\n### ${heading}\n\n${lines.join('\n')}\n` : ''
}

/**
* Normalizes a configured log directory string by expanding home-directory shorthand.
* - `~` becomes the current user's home directory
Expand Down Expand Up @@ -204,12 +217,14 @@ export function formatWorktreeCompletionEntry(
},
planText: string | null,
usage?: LoopUsageSummary | null,
sections?: SectionDigestEntry[] | null,
): string {
const timestamp = options.completionTimestamp.toISOString()
const branchInfo = options.worktreeBranch ? `\n- **Branch:** ${options.worktreeBranch}` : ''
const planSection = (planText?.trim()) || 'Plan unavailable'

const usageSection = usage ? `\n### Usage\n\n${formatUsageSummary(usage).join('\n')}\n` : ''
const usageSection = optionalMarkdownSection('Usage', usage ? formatUsageSummary(usage) : [])
const sectionsSection = optionalMarkdownSection('Sections', formatSectionSummaries(sections ?? []))

return `# ${options.projectDir}

Expand All @@ -218,8 +233,7 @@ export function formatWorktreeCompletionEntry(
- **Loop:** ${options.loopName}${branchInfo}
- **Completed:** ${timestamp}
- **Iteration:** ${options.iteration}
${usageSection}
### Plan
${usageSection}${sectionsSection}### Plan

${planSection}

Expand All @@ -245,12 +259,13 @@ export function appendWorktreeLogEntry(
planText?: string | null,
logger?: Logger,
usage?: LoopUsageSummary | null,
sections?: SectionDigestEntry[] | null,
): boolean {
try {
const dateKey = formatDateKey(options.completionTimestamp)
const logFile = join(directory, `${dateKey}.md`)

const entry = formatWorktreeCompletionEntry(options, planText ?? null, usage)
const entry = formatWorktreeCompletionEntry(options, planText ?? null, usage, sections)

appendFileSync(logFile, entry, 'utf-8')
logger?.debug(`Worktree log: appended entry to ${logFile}`)
Expand All @@ -277,11 +292,11 @@ export function buildWorktreeCompletionPayload(
worktreeBranch?: string
dataDir?: string
usage?: LoopUsageSummary | null
sections?: SectionDigestEntry[] | null
},
logger?: Logger,
): BuildWorktreeCompletionPayloadResult | null {
const worktreeLogging = config.loop?.worktreeLogging
if (!worktreeLogging?.enabled) {
if (!isWorktreeLoggingEnabled(config)) {
logger?.debug('Worktree logging: disabled, skipping')
return null
}
Expand All @@ -304,6 +319,7 @@ export function buildWorktreeCompletionPayload(
iteration: options.iteration,
worktreeBranch: options.worktreeBranch,
usage: options.usage,
sections: options.sections,
}

return {
Expand Down Expand Up @@ -342,7 +358,7 @@ export function writeWorktreeCompletionLog(
payload.planText,
logger,
payload.usage,
payload.sections,
)
}


Loading