diff --git a/src/resources/extensions/sf/auto-post-unit.ts b/src/resources/extensions/sf/auto-post-unit.ts index 6d1adf834..59be993cc 100644 --- a/src/resources/extensions/sf/auto-post-unit.ts +++ b/src/resources/extensions/sf/auto-post-unit.ts @@ -613,6 +613,87 @@ export async function postUnitPreVerification(pctx: PostUnitContext, opts?: PreV }); } + // #4765 — slice-cadence collapse. When `git.collapse_cadence: "slice"` + // is set, squash-merge the slice's commits from the milestone branch + // onto main right here, so orphan risk shrinks from milestone-size to + // slice-size. Only runs in worktree isolation mode — the feature needs + // a milestone branch to squash from. + let sliceMergeStopped = false; + await runSafely("postUnit", "slice-cadence-merge", async () => { + const prefsResult = loadEffectiveSFPreferences(); + const prefs = prefsResult?.preferences; + const { getCollapseCadence, mergeSliceToMain } = await import("./slice-cadence.js"); + if (getCollapseCadence(prefs) !== "slice") return; + if (prefs?.git?.isolation !== "worktree") return; + if (s.isolationDegraded) return; + + const projectRoot = s.originalBasePath || s.basePath; + const { milestone: mid, slice: sid } = parseUnitId(unit.id); + if (!mid || !sid) return; + + // Record the milestone start SHA before the first slice merge, so + // resquashMilestoneOnMain has a target at milestone completion. + // Resolve main branch dynamically — hard-coding "main" breaks repos + // that use "master" or a custom default branch. + if (!s.milestoneStartShas.has(mid)) { + try { + const { nativeDetectMainBranch } = await import("./native-git-bridge.js"); + const mainBranch = nativeDetectMainBranch(projectRoot); + const { execFileSync } = await import("node:child_process"); + const sha = execFileSync("git", ["rev-parse", mainBranch], { + cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8", + }).trim(); + if (sha) s.milestoneStartShas.set(mid, sha); + } catch (err) { + logWarning("engine", `slice-cadence: failed to record milestone start SHA: ${err instanceof Error ? err.message : String(err)}`); + } + } + + try { + const result = mergeSliceToMain(projectRoot, mid, sid); + if (result.skipped) { + logWarning("engine", `slice-cadence: merge skipped for ${sid} — ${result.skippedReason}`); + return; + } + ctx.ui.notify( + `slice-cadence: ${sid} merged to main (${result.durationMs}ms).`, + "info", + ); + } catch (err) { + const { MergeConflictError } = await import("./git-service.js"); + if (err instanceof MergeConflictError) { + ctx.ui.notify( + `slice-cadence merge conflict in ${sid}: ${err.conflictedFiles.join(", ")}. ` + + `Resolve manually on main and run \`/sf auto\` to resume.`, + "error", + ); + // Stop auto AND signal the outer postUnit flow to exit early. + // Without the flag, subsequent hooks (triage, rogue detection, + // DB writes) would keep running against a conflicted main + // checkout after the loop was already told to stop. + const { stopAuto } = await import("./auto.js"); + await stopAuto(ctx, undefined, `slice-merge-conflict on ${sid}`); + sliceMergeStopped = true; + return; + } + logError("engine", `slice-cadence merge failed for ${sid}`, { + error: err instanceof Error ? err.message : String(err), + }); + // Non-conflict failures (dirty main, rev-walk error, etc.) can + // leave the checkout in an unexpected state. Stop auto-mode so + // the next slice doesn't dispatch on top of it. + const { stopAuto } = await import("./auto.js"); + await stopAuto(ctx, undefined, `slice-merge-error on ${sid}`); + sliceMergeStopped = true; + } + }); + // Exit early after stopAuto so the rest of post-unit processing + // (triage, rogue detection, hook dispatch, DB writes) doesn't run + // against a conflicted main checkout. Return "dispatched" to match + // the convention used by other stop/pauseAuto paths in this function + // (see signal handling earlier: stop/pause also return "dispatched"). + if (sliceMergeStopped) return "dispatched"; + // Post-triage: execute actionable resolutions if (s.currentUnit.type === "triage-captures") { try { diff --git a/src/resources/extensions/sf/auto-start.ts b/src/resources/extensions/sf/auto-start.ts index 9fd5e5a1c..3f15351b0 100644 --- a/src/resources/extensions/sf/auto-start.ts +++ b/src/resources/extensions/sf/auto-start.ts @@ -46,6 +46,7 @@ import { nativeBranchListMerged, nativeBranchDelete, nativeWorktreeRemove, + nativeCommitCountBetween, } from "./native-git-bridge.js"; import { GitServiceImpl } from "./git-service.js"; import { @@ -56,12 +57,14 @@ import { import { getAutoWorktreePath, isInAutoWorktree } from "./auto-worktree.js"; import { readResourceVersion, cleanStaleRuntimeUnits } from "./auto-worktree.js"; import { worktreePath as getWorktreeDir, isInsideWorktreesDir } from "./worktree-manager.js"; +import { emitWorktreeOrphaned } from "./worktree-telemetry.js"; import { initMetrics } from "./metrics.js"; import { initRoutingHistory } from "./routing-history.js"; import { restoreHookState, resetHookState } from "./post-unit-hooks.js"; import { resetProactiveHealing, setLevelChangeCallback } from "./doctor-proactive.js"; import { snapshotSkills } from "./skill-discovery.js"; import { isDbAvailable, getMilestone, openDatabase } from "./sf-db.js"; +import { isClosedStatus } from "./status-guards.js"; import { hideFooter } from "./auto-dashboard.js"; import { debugLog, @@ -202,8 +205,59 @@ export function auditOrphanedMilestoneBranches( const milestoneId = branch.replace(/^milestone\//, ""); const milestone = getMilestone(milestoneId); - // Only audit completed milestones - if (!milestone || milestone.status !== "complete") continue; + if (!milestone) continue; + + // #4762 — in-progress milestone branch with unmerged commits ahead of + // main. This is the pre-completion orphan case: auto-mode exited without + // completing the milestone (pause, stop, crash, merge error, blocker) and + // work is stranded on the branch or in the worktree. Data safety first: + // we never delete or touch; we just surface a warning so the user knows + // where to look. + // + // Gate on isClosedStatus so we only warn about genuinely open milestones. + // Parked/other closed statuses go through the legacy complete/unmerged + // path below where appropriate. + if (!isClosedStatus(milestone.status)) { + const isMergedForInProgress = mergedBranches.has(branch); + if (isMergedForInProgress) continue; // nothing to recover + let commitsAhead = 0; + try { + commitsAhead = nativeCommitCountBetween(basePath, mainBranch, branch); + } catch { + // Rev-walk failure — skip rather than noise + continue; + } + if (commitsAhead === 0) continue; + + const wtDir = getWorktreeDir(basePath, milestoneId); + const wtDirExists = existsSync(wtDir); + const wtSuffix = wtDirExists + ? ` Worktree directory at .sf/worktrees/${milestoneId}/ holds the live work.` + : ""; + warnings.push( + `Branch ${branch} has ${commitsAhead} commit(s) ahead of ${mainBranch} for in-progress milestone ${milestoneId}.` + + wtSuffix + + ` Run \`/sf auto\` to resume, or merge manually if abandoning.`, + ); + + // #4764 telemetry + try { + emitWorktreeOrphaned(basePath, milestoneId, { + reason: "in-progress-unmerged", + commitsAhead, + worktreeDirExists: wtDirExists, + }); + } catch (err) { + logWarning("engine", `worktree-orphaned telemetry failed for ${milestoneId}: ${err instanceof Error ? err.message : String(err)}`); + } + + continue; + } + + // Only the "complete" status participates in the merged/unmerged cleanup + // paths below — other closed statuses (parked, etc.) are intentionally + // left alone. + if (milestone.status !== "complete") continue; const isMerged = mergedBranches.has(branch); @@ -251,6 +305,16 @@ export function auditOrphanedMilestoneBranches( `Branch ${branch} exists for completed milestone ${milestoneId} but is NOT merged into ${mainBranch}. ` + `This may contain unmerged work. Merge manually or run \`/sf health --fix\` to resolve.`, ); + + // #4764 telemetry + try { + emitWorktreeOrphaned(basePath, milestoneId, { + reason: "complete-unmerged", + worktreeDirExists: existsSync(getWorktreeDir(basePath, milestoneId)), + }); + } catch (err) { + logWarning("engine", `worktree-orphaned telemetry failed for ${milestoneId}: ${err instanceof Error ? err.message : String(err)}`); + } } } diff --git a/src/resources/extensions/sf/auto.ts b/src/resources/extensions/sf/auto.ts index cf37dd5f3..53c8e4c58 100644 --- a/src/resources/extensions/sf/auto.ts +++ b/src/resources/extensions/sf/auto.ts @@ -992,6 +992,43 @@ export async function stopAuto( restoreProjectRootEnv(); restoreMilestoneLockEnv(); + // #4764 — telemetry: record the exit reason and whether the current milestone + // was merged before we entered stopAuto. This is the producer-side signal for + // the #4761 orphan class: milestoneMerged=false + currentMilestoneId present + // is exactly the pattern that strands work. + try { + const { emitAutoExit } = await import("./worktree-telemetry.js"); + type AutoExitReason = + | "pause" | "stop" | "blocked" | "merge-conflict" | "merge-failed" + | "slice-merge-conflict" | "all-complete" | "no-active-milestone" | "other"; + // Normalize the free-form reason to a closed set so the telemetry + // aggregator buckets stably. Raw detail is preserved in the phases.ts + // notification and the notify'd error string. + const rawReason = reason ?? "stop"; + const normalizedReason: AutoExitReason = rawReason.startsWith("Blocked:") + ? "blocked" + : rawReason.startsWith("Merge conflict") + ? "merge-conflict" + : rawReason.startsWith("Merge error") || rawReason.startsWith("Merge failed") + ? "merge-failed" + : rawReason.startsWith("slice-merge-conflict") + ? "slice-merge-conflict" + : rawReason === "All milestones complete" + ? "all-complete" + : rawReason === "No active milestone" + ? "no-active-milestone" + : rawReason === "stop" || rawReason === "pause" + ? rawReason + : "other"; + emitAutoExit(s.originalBasePath || s.basePath, { + reason: normalizedReason, + milestoneId: s.currentMilestoneId ?? undefined, + milestoneMerged: s.milestoneMergedInPhases === true, + }); + } catch (err) { + logWarning("engine", `auto-exit telemetry failed: ${err instanceof Error ? err.message : String(err)}`); + } + // Drop the active-tool baseline so a subsequent /sf auto run on the // same `pi` instance recaptures from the live tool set rather than // restoring this session's snapshot and silently undoing any tool diff --git a/src/resources/extensions/sf/auto/session.ts b/src/resources/extensions/sf/auto/session.ts index d0adfd73f..44160e413 100644 --- a/src/resources/extensions/sf/auto/session.ts +++ b/src/resources/extensions/sf/auto/session.ts @@ -191,6 +191,13 @@ export class AutoSession { */ pendingCommitTaskContext: TaskCommitContext | null = null; + // ── Slice-cadence start SHAs (#4765) ──────────────────────────────────── + // #4765 — slice-cadence collapse: main-branch SHAs at the moment each + // milestone's first slice merge began. Used by resquashMilestoneOnMain at + // milestone completion to collapse N slice commits into one. Cleared when + // the milestone finishes (or resquash runs). + milestoneStartShas: Map = new Map(); + // ── Signal handler ─────────────────────────────────────────────────────── sigtermHandler: (() => void) | null = null; @@ -288,6 +295,8 @@ export class AutoSession { this.stagedPendingCommit = false; this.pendingCommitTaskContext = null; + this.milestoneStartShas = new Map(); + // Signal handler this.sigtermHandler = null; diff --git a/src/resources/extensions/sf/commands-scan.ts b/src/resources/extensions/sf/commands-scan.ts new file mode 100644 index 000000000..ec42a16fa --- /dev/null +++ b/src/resources/extensions/sf/commands-scan.ts @@ -0,0 +1,124 @@ +/** + * SF Command — /sf scan + * + * Rapid codebase assessment — lightweight alternative to /sf map-codebase. + * Spawns one focused AI analysis pass and writes structured documents to + * .sf/codebase/ for use by planning and execution phases. + * + * Usage: + * /sf scan — tech+arch focus (default) + * /sf scan --focus tech — technology stack + integrations only + * /sf scan --focus arch — architecture + structure only + * /sf scan --focus quality — conventions + testing patterns only + * /sf scan --focus concerns — technical debt + concerns only + * /sf scan --focus tech+arch — explicit default (same as no flag) + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@singularity-forge/pi-coding-agent"; + +import { existsSync, mkdirSync } from "node:fs"; +import { join, relative } from "node:path"; + +import { loadPrompt } from "./prompt-loader.js"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +export const DEFAULT_FOCUS = "tech+arch"; + +export const VALID_FOCUS_AREAS = ["tech", "arch", "quality", "concerns", "tech+arch"] as const; +export type FocusArea = (typeof VALID_FOCUS_AREAS)[number]; + +const FOCUS_DOCUMENTS: Record = { + tech: ["STACK", "INTEGRATIONS"], + arch: ["ARCHITECTURE", "STRUCTURE"], + quality: ["CONVENTIONS", "TESTING"], + concerns: ["CONCERNS"], + "tech+arch": ["STACK", "INTEGRATIONS", "ARCHITECTURE", "STRUCTURE"], +}; + +// ─── Exported functions (exported for testing) ─────────────────────────────── + +/** + * Parse --focus flag from raw args string. + * Returns default focus when flag is missing or the value is invalid. + * Shell-injection safe: only well-known values are accepted. + */ +export function parseScanArgs(args: string): { focus: string } { + const match = args.match(/--focus\s+([^\s]+)/i); + if (!match) return { focus: DEFAULT_FOCUS }; + + const raw = match[1].toLowerCase(); + if ((VALID_FOCUS_AREAS as readonly string[]).includes(raw)) { + return { focus: raw }; + } + return { focus: DEFAULT_FOCUS }; +} + +/** + * Return the list of document names (without extension) to generate for a focus. + * Falls back to the default focus documents for unknown values. + */ +export function resolveScanDocuments(focus: string): string[] { + return FOCUS_DOCUMENTS[focus as FocusArea] ?? FOCUS_DOCUMENTS[DEFAULT_FOCUS]; +} + +/** + * Build absolute output paths for the documents produced by a scan focus. + * All documents live under /.sf/codebase/ + */ +export function buildScanOutputPaths(focus: string, basePath: string): string[] { + const docs = resolveScanDocuments(focus); + return docs.map((doc) => join(basePath, ".sf", "codebase", `${doc}.md`)); +} + +/** + * Return the subset of paths that already exist on disk. + */ +export function checkExistingDocuments(paths: string[]): string[] { + return paths.filter((p) => existsSync(p)); +} + +// ─── Command handler ────────────────────────────────────────────────────────── + +export async function handleScan( + args: string, + ctx: ExtensionCommandContext, + pi: ExtensionAPI, +): Promise { + const basePath = process.cwd(); + const { focus } = parseScanArgs(args); + const outputDir = join(basePath, ".sf", "codebase"); + const outputPaths = buildScanOutputPaths(focus, basePath); + const existing = checkExistingDocuments(outputPaths); + + if (existing.length > 0) { + const names = existing.map((p) => relative(outputDir, p)).join(", "); + ctx.ui.notify( + `Existing documents will be overwritten: ${names}\nContinuing scan with focus: ${focus}`, + "warning", + ); + } + + mkdirSync(outputDir, { recursive: true }); + + const documents = resolveScanDocuments(focus); + + ctx.ui.notify(`Running codebase scan (focus: ${focus})…`, "info"); + + try { + const prompt = loadPrompt("scan", { + focus, + documents: documents.join(", "), + outputDir: outputDir.replaceAll("\\", "/"), + workingDirectory: basePath, + }); + + pi.sendMessage( + { customType: "sf-scan", content: prompt, display: false }, + { triggerTurn: true }, + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + ctx.ui.notify(`Failed to dispatch scan: ${msg}`, "error"); + } +} diff --git a/src/resources/extensions/sf/commands/catalog.ts b/src/resources/extensions/sf/commands/catalog.ts index 1811896ff..9327c68da 100644 --- a/src/resources/extensions/sf/commands/catalog.ts +++ b/src/resources/extensions/sf/commands/catalog.ts @@ -15,7 +15,7 @@ export interface GsdCommandDefinition { type CompletionMap = Record; export const SF_COMMAND_DESCRIPTION = - "SF — Singularity Forge: /sf help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests"; + "SF — Singularity Forge: /sf help|start|templates|next|auto|stop|pause|status|widget|visualize|queue|quick|discuss|capture|triage|dispatch|history|undo|undo-task|reset-slice|rate|skip|export|cleanup|model|mode|prefs|config|keys|hooks|run-hook|skill-health|doctor|logs|forensics|changelog|migrate|remote|steer|knowledge|new-milestone|parallel|cmux|park|unpark|init|setup|inspect|extensions|update|fast|mcp|rethink|codebase|notifications|ship|do|session-report|backlog|pr-branch|add-tests|scan"; export const TOP_LEVEL_SUBCOMMANDS: readonly GsdCommandDefinition[] = [ { cmd: "help", desc: "Categorized command reference with descriptions" }, diff --git a/src/resources/extensions/sf/commands/handlers/ops.ts b/src/resources/extensions/sf/commands/handlers/ops.ts index 2b4f88dba..7031b129e 100644 --- a/src/resources/extensions/sf/commands/handlers/ops.ts +++ b/src/resources/extensions/sf/commands/handlers/ops.ts @@ -47,6 +47,11 @@ export async function handleOpsCommand(trimmed: string, ctx: ExtensionCommandCon await handleForensics(trimmed.replace(/^forensics\s*/, "").trim(), ctx, pi); return true; } + if (trimmed === "scan" || trimmed.startsWith("scan ")) { + const { handleScan } = await import("../../commands-scan.js"); + await handleScan(trimmed.replace(/^scan\s*/, "").trim(), ctx, pi); + return true; + } if (trimmed === "changelog" || trimmed.startsWith("changelog ")) { const { handleChangelog } = await import("../../changelog.js"); await handleChangelog(trimmed.replace(/^changelog\s*/, "").trim(), ctx, pi); diff --git a/src/resources/extensions/sf/forensics.ts b/src/resources/extensions/sf/forensics.ts index 5cf71b9b7..3dfebf7bc 100644 --- a/src/resources/extensions/sf/forensics.ts +++ b/src/resources/extensions/sf/forensics.ts @@ -36,11 +36,12 @@ import { getAutoWorktreePath } from "./auto-worktree.js"; import { loadEffectiveSFPreferences, loadGlobalSFPreferences, getGlobalSFPreferencesPath } from "./preferences.js"; import { showNextAction } from "../shared/tui.js"; import { ensurePreferencesFile, serializePreferencesToFrontmatter } from "./commands-prefs-wizard.js"; +import { summarizeWorktreeTelemetry, percentile, type WorktreeTelemetrySummary } from "./worktree-telemetry.js"; // ─── Types ──────────────────────────────────────────────────────────────────── export interface ForensicAnomaly { - type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace" | "journal-stuck" | "journal-guard-block" | "journal-rapid-iterations" | "journal-worktree-failure"; + type: "stuck-loop" | "cost-spike" | "timeout" | "missing-artifact" | "crash" | "doctor-issue" | "error-trace" | "journal-stuck" | "journal-guard-block" | "journal-rapid-iterations" | "journal-worktree-failure" | "worktree-orphan" | "worktree-unmerged-exit"; severity: "info" | "warning" | "error"; unitType?: string; unitId?: string; @@ -114,6 +115,8 @@ interface ForensicReport { recentUnits: { type: string; id: string; cost: number; duration: number; model: string; finishedAt: number }[]; journalSummary: JournalSummary | null; activityLogMeta: ActivityLogMeta | null; + /** #4764 — worktree lifespan / divergence telemetry aggregates. */ + worktreeTelemetry: WorktreeTelemetrySummary | null; } // ─── Duplicate Detection ────────────────────────────────────────────────────── @@ -340,6 +343,16 @@ export async function buildForensicReport(basePath: string): Promise 0) { + const reasonBreakdown = Object.entries(summary.exitsByReason) + .filter(([, n]) => n > 0) + .map(([r, n]) => `${r}=${n}`) + .join(", "); + anomalies.push({ + type: "worktree-unmerged-exit", + severity: "warning", + summary: `${summary.exitsWithUnmergedWork} auto-exit(s) left milestone work unmerged`, + details: `Exit reasons: ${reasonBreakdown || "(none)"} · Producer-side signal for #4761-class orphans. Inspect .sf/journal/*.jsonl with eventType:"auto-exit" for per-exit detail.`, + }); + } +} + // ─── Report Persistence ─────────────────────────────────────────────────────── function saveForensicReport(basePath: string, report: ForensicReport, problemDescription: string): string { @@ -977,6 +1036,40 @@ function saveForensicReport(basePath: string, report: ForensicReport, problemDes sections.push(``); } + // #4764 — Worktree telemetry summary + if (report.worktreeTelemetry) { + const t = report.worktreeTelemetry; + const p50 = percentile(t.mergeDurationsMs, 0.5); + const p95 = percentile(t.mergeDurationsMs, 0.95); + sections.push(`## Worktree Telemetry`, ``); + sections.push(`- Worktrees created: ${t.worktreesCreated}`); + sections.push(`- Worktrees merged: ${t.worktreesMerged}`); + sections.push(`- Orphans detected: ${t.orphansDetected}`); + if (t.orphansDetected > 0) { + const breakdown = Object.entries(t.orphansByReason) + .map(([r, n]) => `${r}=${n}`).join(", "); + sections.push(` - By reason: ${breakdown}`); + } + sections.push(`- Merge conflicts: ${t.mergeConflicts}`); + if (t.mergeDurationsMs.length > 0) { + sections.push(`- Merge duration p50 / p95: ${p50 ?? "-"} / ${p95 ?? "-"} ms (n=${t.mergeDurationsMs.length})`); + } + sections.push(`- Auto-exits leaving unmerged work: ${t.exitsWithUnmergedWork}`); + if (Object.keys(t.exitsByReason).length > 0) { + const breakdown = Object.entries(t.exitsByReason) + .sort((a, b) => b[1] - a[1]) + .map(([r, n]) => `${r}=${n}`).join(", "); + sections.push(` - Exit reasons: ${breakdown}`); + } + sections.push(`- Canonical-root redirects (#4761 fix fired): ${t.canonicalRedirects}`); + // #4765 slice-cadence counters + if (t.slicesMerged + t.sliceMergeConflicts + t.milestoneResquashes > 0) { + sections.push(`- Slices merged: ${t.slicesMerged} · Slice merge conflicts: ${t.sliceMergeConflicts}`); + sections.push(`- Milestone re-squashes: ${t.milestoneResquashes}`); + } + sections.push(``); + } + // Journal summary if (report.journalSummary) { const js = report.journalSummary; @@ -1157,6 +1250,30 @@ function formatReportForPrompt(report: ForensicReport): string { sections.push(""); } + // #4764 — worktree telemetry (compact prompt form) + if (report.worktreeTelemetry) { + const t = report.worktreeTelemetry; + const hasSignal = + t.worktreesCreated + t.worktreesMerged + t.orphansDetected + + t.exitsWithUnmergedWork + t.canonicalRedirects + + t.slicesMerged + t.milestoneResquashes > 0; + if (hasSignal) { + sections.push("### Worktree Telemetry"); + sections.push(`- Created: ${t.worktreesCreated} · Merged: ${t.worktreesMerged} · Conflicts: ${t.mergeConflicts}`); + sections.push(`- Orphans: ${t.orphansDetected} · Unmerged exits: ${t.exitsWithUnmergedWork} · Redirects (#4761): ${t.canonicalRedirects}`); + if (t.orphansDetected > 0) { + const breakdown = Object.entries(t.orphansByReason) + .map(([r, n]) => `${r}=${n}`).join(", "); + sections.push(`- Orphan reasons: ${breakdown}`); + } + // #4765 — slice-cadence counters (only shown when the feature was exercised) + if (t.slicesMerged + t.sliceMergeConflicts + t.milestoneResquashes > 0) { + sections.push(`- Slices merged: ${t.slicesMerged} · Slice conflicts: ${t.sliceMergeConflicts} · Re-squashes: ${t.milestoneResquashes}`); + } + sections.push(""); + } + } + // Completion status — prefer DB counts, fall back to legacy completed-units.json if (report.dbCompletionCounts) { const c = report.dbCompletionCounts; diff --git a/src/resources/extensions/sf/git-service.ts b/src/resources/extensions/sf/git-service.ts index 0c43fb17a..c51c697ef 100644 --- a/src/resources/extensions/sf/git-service.ts +++ b/src/resources/extensions/sf/git-service.ts @@ -85,6 +85,22 @@ export interface GitPreferences { * for forensic inspection. */ absorb_snapshot_commits?: boolean; + /** #4765 — when to collapse worktree commits back to main. + * - "milestone" (default): existing behavior — squash-merge happens once + * at milestone completion or transition. + * - "slice": squash-merge each slice's commits to main as soon as the + * slice passes validation. Shrinks the orphan window from + * milestone-size to slice-size and surfaces merge conflicts per slice + * rather than all at once at milestone end. + */ + collapse_cadence?: "milestone" | "slice"; + /** #4765 — when `collapse_cadence: "slice"`, optionally re-squash the per- + * slice commits on main into one milestone commit at milestone completion. + * Preserves the "one commit per milestone in main" history shape that + * `collapse_cadence: "milestone"` produces today. + * Default: true when collapse_cadence is "slice", ignored otherwise. + */ + milestone_resquash?: boolean; } export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/; diff --git a/src/resources/extensions/sf/journal.ts b/src/resources/extensions/sf/journal.ts index 50fb4136a..35b8f0c16 100644 --- a/src/resources/extensions/sf/journal.ts +++ b/src/resources/extensions/sf/journal.ts @@ -39,7 +39,17 @@ export type JournalEventType = | "worktree-create-failed" | "worktree-skip" | "worktree-merge-start" - | "worktree-merge-failed"; + | "worktree-merge-failed" + // #4764 — worktree lifespan / divergence telemetry + | "worktree-created" + | "worktree-merged" + | "worktree-orphaned" + | "auto-exit" + | "worktree-sync" + | "canonical-root-redirect" + // #4765 — slice-cadence collapse + | "slice-merged" + | "milestone-resquash"; /** A single structured event in the journal. */ export interface JournalEntry { diff --git a/src/resources/extensions/sf/preferences-validation.ts b/src/resources/extensions/sf/preferences-validation.ts index 8b40a0558..731752a7b 100644 --- a/src/resources/extensions/sf/preferences-validation.ts +++ b/src/resources/extensions/sf/preferences-validation.ts @@ -1100,6 +1100,28 @@ export function validatePreferences(preferences: SFPreferences): { warnings.push("git.merge_to_main is deprecated — milestone-level merge is now always used. Remove this setting."); } + // #4765 — collapse cadence + milestone resquash + if (g.collapse_cadence !== undefined) { + const validCadence = new Set(["milestone", "slice"]); + if (typeof g.collapse_cadence === "string" && validCadence.has(g.collapse_cadence)) { + git.collapse_cadence = g.collapse_cadence as "milestone" | "slice"; + } else { + errors.push("git.collapse_cadence must be one of: milestone, slice"); + } + } + if (g.milestone_resquash !== undefined) { + if (typeof g.milestone_resquash === "boolean") { + git.milestone_resquash = g.milestone_resquash; + const cadence = (git.collapse_cadence as string | undefined) + ?? (typeof g.collapse_cadence === "string" ? g.collapse_cadence : undefined); + if (cadence !== "slice") { + warnings.push('git.milestone_resquash is ignored unless git.collapse_cadence is "slice"'); + } + } else { + errors.push("git.milestone_resquash must be a boolean"); + } + } + if (Object.keys(git).length > 0) { validated.git = git as GitPreferences; } diff --git a/src/resources/extensions/sf/prompts/scan.md b/src/resources/extensions/sf/prompts/scan.md new file mode 100644 index 000000000..ab8d29fe5 --- /dev/null +++ b/src/resources/extensions/sf/prompts/scan.md @@ -0,0 +1,79 @@ +You are performing a focused codebase scan. + +## Scan Parameters + +- **Focus:** {{focus}} +- **Documents to produce:** {{documents}} +- **Output directory:** `{{outputDir}}` + +## Working Directory + +`{{workingDirectory}}` + +## Instructions + +1. Explore the codebase to understand its structure, technology choices, and patterns +2. For each document listed above, produce a well-structured Markdown file in `{{outputDir}}/` +3. Use the document schemas below as a guide for each output file + +For this scan, only these documents are relevant: **{{documents}}**. Refer only to those schemas below and ignore the rest. + +### Document Schemas + +**STACK.md** — Technology stack overview +- Languages, runtimes, and versions +- Key frameworks and libraries (with versions where visible) +- Build tools and bundlers +- Package manager + +**INTEGRATIONS.md** — External dependencies and integrations +- Third-party APIs and services +- Database systems +- Authentication providers +- Infrastructure and deployment platforms +- Communication services (email, messaging, etc.) + +**ARCHITECTURE.md** — Architectural patterns and design decisions +- Overall architecture style (monolith, microservices, monorepo, etc.) +- Core data flow +- Key design patterns in use +- Module/package boundaries + +**STRUCTURE.md** — Directory and code organization +- Top-level directory layout with purpose +- Source code organization +- Test organization +- Configuration file locations + +**CONVENTIONS.md** — Coding conventions and standards +- Naming conventions (files, functions, variables) +- Code style and formatting rules +- Import/export patterns +- Error handling patterns +- TypeScript/language-specific conventions + +**TESTING.md** — Testing patterns and practices +- Test framework(s) in use +- Test file naming and location conventions +- Test helper and fixture patterns +- Coverage requirements (if any) +- How to run tests + +**CONCERNS.md** — Technical debt and risks +- Known areas of technical debt +- Fragile or high-risk code areas +- Missing test coverage +- Outdated dependencies +- Performance bottlenecks +- Security considerations + +## Rules + +- Write only the documents listed in **Documents to produce** — do not generate extra files +- Each document must be a clean, standalone Markdown file starting with a `# Heading` +- Be factual: report what you observe in the code, not what might be ideal +- Keep each document focused and scannable — use headers, bullet points, and code snippets +- Do NOT modify any source files +- After writing all documents, summarize what was produced (file names and line counts) + +{{skillActivation}} diff --git a/src/resources/extensions/sf/slice-cadence.ts b/src/resources/extensions/sf/slice-cadence.ts new file mode 100644 index 000000000..d2e1740a9 --- /dev/null +++ b/src/resources/extensions/sf/slice-cadence.ts @@ -0,0 +1,299 @@ +/** + * Slice-cadence collapse — #4765. + * + * When `git.collapse_cadence: "slice"` is set, each slice's commits are + * squash-merged from the milestone branch to main as soon as the slice + * passes validation. Shrinks the orphan window (#4761) from milestone-size + * to slice-size and surfaces merge conflicts per-slice rather than all at + * once at milestone end. + * + * This module is deliberately focused and narrower than mergeMilestoneToMain: + * - No worktree teardown (worktree is reused for the next slice) + * - No DB reconciliation (modern worktrees share the main DB via path resolver) + * - No roadmap/summary/gate handling (that's still the milestone's job) + * - Fails loudly on dirty main — caller is responsible for cleanliness + * + * Kernighan: the v1 surface handles the happy path + conflict. Edge cases + * that mergeMilestoneToMain covers (concurrent merges, shared DB paths, + * submodules) are explicit non-goals; users opt in via preference and early- + * adopter scenarios are scoped narrow. + */ + +import { existsSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; + +import { SFError, SF_GIT_ERROR } from "./errors.js"; +import { MergeConflictError } from "./git-service.js"; +import { + nativeBranchForceReset, + nativeCheckoutBranch, + nativeCommit, + nativeCommitCountBetween, + nativeConflictFiles, + nativeDetectMainBranch, + nativeMergeSquash, +} from "./native-git-bridge.js"; +import { resolveGitDir } from "./worktree-manager.js"; +import { logWarning } from "./workflow-logger.js"; +import { emitSliceMerged, emitMilestoneResquash } from "./worktree-telemetry.js"; + +/** + * Auto-worktree milestone branch name. Must match autoWorktreeBranch() in + * auto-worktree.ts; duplicated here to avoid a cyclic import. + */ +function milestoneBranchName(milestoneId: string): string { + return `milestone/${milestoneId}`; +} + +function cleanupMergeArtifacts(projectRoot: string): void { + try { + const gitDir = resolveGitDir(projectRoot); + for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) { + const p = join(gitDir, f); + if (existsSync(p)) unlinkSync(p); + } + } catch (err) { + logWarning("worktree", `merge artifact cleanup failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +export interface SliceMergeResult { + commitSha: string | null; + mainBranch: string; + milestoneBranch: string; + durationMs: number; + skipped: boolean; + skippedReason?: string; +} + +/** + * Squash-merge one slice's commits from the milestone branch to main. + * + * Preconditions: + * - Caller is on the milestone branch inside the worktree + * - `projectRoot` points at the real project root (not the worktree) + * + * Post-conditions on success: + * - Slice's commits are a single squash commit on main + * - `milestone/` is fast-forwarded to main (so next slice's work + * starts from a clean base) + * - caller's process.cwd is restored + * + * Throws MergeConflictError on conflicts; caller should surface and stop. + * Throws SFError on dirty main / detection failures. + */ +export function mergeSliceToMain( + projectRoot: string, + milestoneId: string, + sliceId: string, +): SliceMergeResult { + const started = Date.now(); + const worktreeCwd = process.cwd(); + const milestoneBranch = milestoneBranchName(milestoneId); + const mainBranch = nativeDetectMainBranch(projectRoot); + + // Fast path: if the milestone branch has no commits ahead of main, there + // is nothing to merge. Return a skip result instead of no-op'ing silently + // so the caller's telemetry shows the decision. + let commitsAhead = 0; + try { + commitsAhead = nativeCommitCountBetween(projectRoot, mainBranch, milestoneBranch); + } catch { + // If we can't count, assume there's work and let the merge proceed — + // a failing merge is more informative than a silent skip. + commitsAhead = 1; + } + if (commitsAhead === 0) { + // Do NOT emit slice-merged here — this is a no-op, not a merge. Emitting + // would inflate slicesMerged in telemetry/forensics and distort the + // conflict rate denominator. + return { + commitSha: null, + mainBranch, + milestoneBranch, + durationMs: Date.now() - started, + skipped: true, + skippedReason: "no-commits-ahead", + }; + } + + process.chdir(projectRoot); + try { + // Dirty-main check — v1 fails loudly rather than auto-stashing. Users + // running slice-cadence opt in knowing main stays clean between merges. + const status = execFileSync("git", ["status", "--porcelain"], { + cwd: projectRoot, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }).trim(); + if (status) { + throw new SFError( + SF_GIT_ERROR, + `slice-cadence merge requires a clean project root; uncommitted changes detected. ` + + `Commit or stash at ${projectRoot} before retrying. Status:\n${status}`, + ); + } + + nativeCheckoutBranch(projectRoot, mainBranch); + + // Clean any stale merge artifacts before attempting the squash (#2912 pattern) + cleanupMergeArtifacts(projectRoot); + + const mergeResult = nativeMergeSquash(projectRoot, milestoneBranch); + if (!mergeResult.success) { + const conflictedFiles = mergeResult.conflicts.length > 0 + ? mergeResult.conflicts + : nativeConflictFiles(projectRoot); + cleanupMergeArtifacts(projectRoot); + try { + emitSliceMerged(projectRoot, milestoneId, sliceId, { + durationMs: Date.now() - started, + conflict: true, + }); + } catch { /* silent */ } + throw new MergeConflictError( + conflictedFiles, + "squash", + milestoneBranch, + mainBranch, + ); + } + + // Commit the squash with a slice-scoped message + const commitSha = nativeCommit( + projectRoot, + `sf: merge ${sliceId} of ${milestoneId} (slice-cadence)`, + ); + + // Advance the milestone branch to main so the next slice's commits start + // from a clean base. Force-reset is safe because we just merged this + // branch's entire delta. + nativeBranchForceReset(projectRoot, milestoneBranch, mainBranch); + + const durationMs = Date.now() - started; + try { + emitSliceMerged(projectRoot, milestoneId, sliceId, { + durationMs, + conflict: false, + commitSha: commitSha ?? undefined, + }); + } catch { /* silent */ } + + return { + commitSha, + mainBranch, + milestoneBranch, + durationMs, + skipped: false, + }; + } finally { + // Always restore cwd even if anything above threw. + try { process.chdir(worktreeCwd); } catch { /* best-effort */ } + } +} + +/** + * Re-squash per-slice commits on main into a single milestone commit. + * + * Runs at milestone completion when `collapse_cadence: "slice"` AND + * `milestone_resquash: true`. The `startSha` is the SHA of main immediately + * before the milestone's first slice merge — the caller is responsible for + * recording this (AutoSession field, git ref, or DB row). + * + * Strategy: soft-reset main to startSha, then commit the net diff. The + * N slice commits between startSha and HEAD are collapsed into one. + * + * No-op (returns false) if startSha equals HEAD (nothing to re-squash). + */ +export function resquashMilestoneOnMain( + projectRoot: string, + milestoneId: string, + startSha: string, +): { resquashed: boolean; newSha: string | null } { + const mainBranch = nativeDetectMainBranch(projectRoot); + const worktreeCwd = process.cwd(); + + process.chdir(projectRoot); + try { + nativeCheckoutBranch(projectRoot, mainBranch); + + // Verify the startSha..HEAD range contains ONLY this milestone's slice- + // cadence commits. If any unrelated commits landed on main since the + // milestone started (e.g. concurrent work, cherry-picks, hotfixes), a + // blind `git reset --soft` would fold them into the re-squash and rewrite + // their attribution. Fail closed — the user can resolve manually. + const expectedSuffix = `(slice-cadence)`; + const expectedMilestoneToken = ` of ${milestoneId} `; + let subjectsRaw = ""; + try { + subjectsRaw = execFileSync( + "git", + ["log", "--format=%s", `${startSha}..HEAD`], + { cwd: projectRoot, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }, + ); + } catch { + return { resquashed: false, newSha: null }; + } + const subjects = subjectsRaw.split("\n").filter((s) => s.length > 0); + const sliceCount = subjects.length; + if (sliceCount === 0) { + return { resquashed: false, newSha: null }; + } + const foreign = subjects.filter( + (s) => !(s.endsWith(expectedSuffix) && s.includes(expectedMilestoneToken)), + ); + if (foreign.length > 0) { + logWarning( + "worktree", + `slice-cadence: skipping milestone resquash for ${milestoneId} — ` + + `${foreign.length} non-slice-cadence commit(s) in ${startSha}..HEAD ` + + `would be folded in. First: "${foreign[0]}". Resolve history manually.`, + ); + return { resquashed: false, newSha: null }; + } + + // Safe to collapse: all commits in the range are this milestone's slices. + execFileSync("git", ["reset", "--soft", startSha], { + cwd: projectRoot, + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf-8", + }); + + const newSha = nativeCommit( + projectRoot, + `sf: complete milestone ${milestoneId} (${sliceCount} slices re-squashed)`, + { allowEmpty: true }, + ); + + try { + emitMilestoneResquash(projectRoot, milestoneId, { + sliceCount, + startSha, + endSha: newSha ?? undefined, + }); + } catch { /* silent */ } + + return { resquashed: true, newSha }; + } finally { + try { process.chdir(worktreeCwd); } catch { /* best-effort */ } + } +} + +/** + * Read the effective collapse cadence from validated preferences. Accepts + * a raw preferences object (the shape loadEffectiveSFPreferences returns). + */ +export function getCollapseCadence( + prefs: { git?: { collapse_cadence?: "milestone" | "slice" } } | undefined | null, +): "milestone" | "slice" { + return prefs?.git?.collapse_cadence ?? "milestone"; +} + +export function getMilestoneResquash( + prefs: { git?: { milestone_resquash?: boolean } } | undefined | null, +): boolean { + // Default true when cadence is slice — resquash preserves the milestone- + // level history shape users expect. + return prefs?.git?.milestone_resquash !== false; +} diff --git a/src/resources/extensions/sf/tools/validate-milestone.ts b/src/resources/extensions/sf/tools/validate-milestone.ts index 7c46ac9de..b3309d4d8 100644 --- a/src/resources/extensions/sf/tools/validate-milestone.ts +++ b/src/resources/extensions/sf/tools/validate-milestone.ts @@ -26,6 +26,7 @@ import { logWarning } from "../workflow-logger.js"; import { UokGateRunner } from "../uok/gate-runner.js"; import { loadEffectiveSFPreferences } from "../preferences.js"; import { resolveUokFlags } from "../uok/flags.js"; +import { resolveCanonicalMilestoneRoot } from "../worktree-manager.js"; export interface ValidateMilestoneParams { milestoneId: string; @@ -102,12 +103,17 @@ export async function handleValidateMilestone( // ── Resolve paths and render markdown ──────────────────────────────── const validationMd = renderValidationMarkdown(params); + // #4761: route through the canonical-root resolver so that when a live + // worktree exists for this milestone, validation reads/writes the + // worktree's artifacts instead of stale project-root state. + const canonicalBase = resolveCanonicalMilestoneRoot(basePath, params.milestoneId); + let validationPath: string; - const milestoneDir = resolveMilestonePath(basePath, params.milestoneId); + const milestoneDir = resolveMilestonePath(canonicalBase, params.milestoneId); if (milestoneDir) { validationPath = join(milestoneDir, `${params.milestoneId}-VALIDATION.md`); } else { - const sfDir = join(basePath, ".sf"); + const sfDir = join(canonicalBase, ".sf"); const manualDir = join(sfDir, "milestones", params.milestoneId); validationPath = join(manualDir, `${params.milestoneId}-VALIDATION.md`); } diff --git a/src/resources/extensions/sf/worktree-manager.ts b/src/resources/extensions/sf/worktree-manager.ts index 8eac3c9b2..c891987d3 100644 --- a/src/resources/extensions/sf/worktree-manager.ts +++ b/src/resources/extensions/sf/worktree-manager.ts @@ -20,6 +20,7 @@ import { execFileSync } from "node:child_process"; import { join, resolve, sep } from "node:path"; import { SFError, SF_PARSE_ERROR, SF_STALE_STATE, SF_LOCK_HELD, SF_GIT_ERROR, SF_MERGE_CONFLICT } from "./errors.js"; import { logWarning } from "./workflow-logger.js"; +import { emitCanonicalRootRedirect } from "./worktree-telemetry.js"; import { nativeBranchDelete, nativeBranchExists, @@ -115,6 +116,58 @@ export function worktreeBranchName(name: string): string { return `worktree/${name}`; } +/** + * Return the canonical path from which a milestone's artifacts should be read. + * + * If a live git worktree exists for this milestone at `.sf/worktrees//` + * (directory present AND a `.git` file indicating a registered worktree), + * returns that worktree path. Otherwise returns `basePath` unchanged. + * + * Readers that cross the session/worktree boundary (validators, the bootstrap + * audit, cross-session state queries) should route through this helper so they + * don't silently read stale project-root state while live work sits in the + * worktree. Writers and tools whose contract is "operate on the path I was + * given" should NOT use this helper — they preserve the legacy behavior. + * + * A stale worktree directory (no `.git` file) is treated as absent. The + * createWorktree() path already cleans these up, but readers must not trust + * them in the window before cleanup runs. + * + * Fixes #4761. Used by the #4762 audit for the pre-completion orphan case. + */ +export function resolveCanonicalMilestoneRoot( + basePath: string, + milestoneId: string, +): string { + if (!milestoneId || /[/\\]|\.\./.test(milestoneId)) return basePath; + + const wtPath = worktreePath(basePath, milestoneId); + if (!existsSync(wtPath)) return basePath; + + // A registered git worktree has a .git *file* (not directory) containing + // "gitdir: ". A standalone .git directory indicates a copied repo + // or nested standalone repo — not a worktree registered with this project — + // and must not be treated as the canonical root. + const gitPath = join(wtPath, ".git"); + if (!existsSync(gitPath)) return basePath; + try { + const stat = lstatSync(gitPath); + if (!stat.isFile()) return basePath; + } catch { + return basePath; + } + + // #4764 — record the redirect so we can measure how often the #4761 fix + // would have mattered. Best-effort; emit is silent on any failure. + try { + emitCanonicalRootRedirect(basePath, milestoneId, wtPath); + } catch (err) { + logWarning("worktree", `canonical-root-redirect telemetry failed: ${err instanceof Error ? err.message : String(err)}`); + } + + return wtPath; +} + /** * Validate that a path is inside the .sf/worktrees/ directory. * Resolves symlinks and normalizes ".." traversals before comparison diff --git a/src/resources/extensions/sf/worktree-resolver.ts b/src/resources/extensions/sf/worktree-resolver.ts index 597a36f33..47b96a076 100644 --- a/src/resources/extensions/sf/worktree-resolver.ts +++ b/src/resources/extensions/sf/worktree-resolver.ts @@ -20,6 +20,9 @@ import type { AutoSession } from "./auto/session.js"; import { debugLog } from "./debug-logger.js"; import { MergeConflictError } from "./git-service.js"; import { emitJournalEvent } from "./journal.js"; +import { emitWorktreeCreated, emitWorktreeMerged } from "./worktree-telemetry.js"; +import { getCollapseCadence, getMilestoneResquash, resquashMilestoneOnMain } from "./slice-cadence.js"; +import { loadEffectiveSFPreferences } from "./preferences.js"; // ─── Dependency Interface ────────────────────────────────────────────────── @@ -213,6 +216,20 @@ export class WorktreeResolver { data: { milestoneId, wtPath, created: !existingPath }, }); ctx.notify(`Entered worktree for ${milestoneId} at ${wtPath}`, "info"); + + // #4764 — record creation/enter as a lifecycle event so the telemetry + // aggregator can pair it with the eventual worktree-merged event. + try { + emitWorktreeCreated(this.s.originalBasePath || this.s.basePath, milestoneId, { + reason: existingPath ? "enter-milestone" : "create-milestone", + }); + } catch (telemetryErr) { + debugLog("WorktreeResolver", { + action: "enterMilestone", + phase: "telemetry-emit", + error: telemetryErr instanceof Error ? telemetryErr.message : String(telemetryErr), + }); + } } catch (err) { const msg = err instanceof Error ? err.message : String(err); debugLog("WorktreeResolver", { @@ -366,17 +383,83 @@ export class WorktreeResolver { return; } + // #4764 — telemetry: record start timestamp so we can emit merge duration. + const mergeStartedAt = new Date().toISOString(); + const mergeStartMs = Date.now(); + + let actuallyMerged = false; if ( mode === "worktree" || inWorktree ) { - this._mergeWorktreeMode(milestoneId, ctx); + actuallyMerged = this._mergeWorktreeMode(milestoneId, ctx); } else if (mode === "branch") { - this._mergeBranchMode(milestoneId, ctx); + actuallyMerged = this._mergeBranchMode(milestoneId, ctx); + } + + // The remainder of this function emits telemetry and runs re-squash. + // Both are gated on actuallyMerged — if the _merge* helper took a + // no-merge path (missing originalBase, no roadmap, wrong branch) the + // milestone branch was intentionally left unmerged and we must not + // emit a worktree-merged event or collapse commits on main. + if (!actuallyMerged) { + // Always clear the start-SHA tracker to avoid leaking across sessions. + this.s.milestoneStartShas.delete(milestoneId); + return; + } + + // #4765 — when collapse_cadence=slice AND milestone_resquash=true, the + // N per-slice commits on main should be collapsed into one milestone + // commit. Done AFTER the primary merge-and-teardown so the branch and + // worktree are already cleaned up; we operate on main directly. + try { + const startSha = this.s.milestoneStartShas.get(milestoneId); + if (startSha) { + const prefs = loadEffectiveSFPreferences()?.preferences; + if (getCollapseCadence(prefs) === "slice" && getMilestoneResquash(prefs)) { + const result = resquashMilestoneOnMain( + this.s.originalBasePath || this.s.basePath, + milestoneId, + startSha, + ); + if (result.resquashed) { + ctx.notify( + `slice-cadence: re-squashed slice commits for ${milestoneId} into a single milestone commit.`, + "info", + ); + } + } + this.s.milestoneStartShas.delete(milestoneId); + } + } catch (err) { + debugLog("WorktreeResolver", { + action: "mergeAndExit", + milestoneId, + phase: "resquash", + error: err instanceof Error ? err.message : String(err), + }); + } + + // #4764 — record merge completion. Only reaches here when an actual + // merge ran; failure paths throw out of _merge* before this point and + // no-merge paths returned above. + try { + emitWorktreeMerged(this.s.originalBasePath || this.s.basePath, milestoneId, { + reason: "milestone-complete", + startedAt: mergeStartedAt, + durationMs: Date.now() - mergeStartMs, + }); + } catch (telemetryErr) { + debugLog("WorktreeResolver", { + action: "mergeAndExit", + phase: "telemetry-emit", + error: telemetryErr instanceof Error ? telemetryErr.message : String(telemetryErr), + }); } } - /** Worktree-mode merge: read roadmap, merge, teardown, reset paths. */ - private _mergeWorktreeMode(milestoneId: string, ctx: NotifyCtx): void { + /** Worktree-mode merge: read roadmap, merge, teardown, reset paths. + * Returns true when a squash-merge actually ran (false on skip paths). */ + private _mergeWorktreeMode(milestoneId: string, ctx: NotifyCtx): boolean { const originalBase = this.s.originalBasePath; if (!originalBase) { debugLog("WorktreeResolver", { @@ -386,9 +469,11 @@ export class WorktreeResolver { skipped: true, reason: "missing-original-base", }); - return; + return false; } + let merged = false; + try { const { synced } = this.deps.syncWorktreeStateBack( originalBase, @@ -437,6 +522,7 @@ export class WorktreeResolver { milestoneId, roadmapContent, ); + merged = true; // #2945 Bug 3: mergeMilestoneToMain performs best-effort worktree // cleanup internally (step 12), but it can silently fail on Windows @@ -538,10 +624,12 @@ export class WorktreeResolver { result: "done", basePath: this.s.basePath, }); + return merged; } - /** Branch-mode merge: check current branch, merge if on milestone branch. */ - private _mergeBranchMode(milestoneId: string, ctx: NotifyCtx): void { + /** Branch-mode merge: check current branch, merge if on milestone branch. + * Returns true when a merge actually ran (false on skip paths). */ + private _mergeBranchMode(milestoneId: string, ctx: NotifyCtx): boolean { try { const currentBranch = this.deps.getCurrentBranch(this.s.basePath); const milestoneBranch = this.deps.autoWorktreeBranch(milestoneId); @@ -556,7 +644,7 @@ export class WorktreeResolver { currentBranch, milestoneBranch, }); - return; + return false; } const roadmapPath = this.deps.resolveMilestoneFile( @@ -572,7 +660,7 @@ export class WorktreeResolver { skipped: true, reason: "no-roadmap", }); - return; + return false; } const roadmapContent = this.deps.readFileSync(roadmapPath, "utf-8"); @@ -603,6 +691,7 @@ export class WorktreeResolver { mode: "branch", result: "success", }); + return true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); debugLog("WorktreeResolver", { @@ -613,6 +702,7 @@ export class WorktreeResolver { error: msg, }); ctx.notify(`Milestone merge failed (branch mode): ${msg}`, "warning"); + return false; } } diff --git a/src/resources/extensions/sf/worktree-telemetry.ts b/src/resources/extensions/sf/worktree-telemetry.ts new file mode 100644 index 000000000..5af87e5bc --- /dev/null +++ b/src/resources/extensions/sf/worktree-telemetry.ts @@ -0,0 +1,322 @@ +/** + * Worktree telemetry — #4764 + * + * Thin emit helpers + aggregator on top of the existing journal. Separate + * module so callers import a tiny surface and don't have to assemble + * JournalEntry records by hand. Kernighan: the underlying emit path + * (emitJournalEvent) is already battle-tested; this module is just + * structured call sites + a summarizer. + * + * Emitted event types (see journal.ts): + * - worktree-created worktree entered/created for a milestone + * - worktree-merged worktree merge back to main completed + * - worktree-orphaned audit detected an orphaned branch/worktree + * - auto-exit auto-mode exited (pause/stop/blocked/error) + * - worktree-sync syncStateToProjectRoot snapshot + * - canonical-root-redirect resolveCanonicalMilestoneRoot redirected + * + * These events are purely observational. They never block, never throw, + * and never carry code content — only IDs, counts, durations, and reasons. + */ + +import { randomUUID } from "node:crypto"; +import { emitJournalEvent, queryJournal } from "./journal.js"; +import type { JournalEntry } from "./journal.js"; + +function now(): string { + return new Date().toISOString(); +} + +function baseEntry(eventType: JournalEntry["eventType"], data: Record): JournalEntry { + return { + ts: now(), + flowId: (typeof data.flowId === "string" ? data.flowId : undefined) ?? randomUUID(), + seq: typeof data.seq === "number" ? data.seq : 0, + eventType, + data, + }; +} + +// ─── Reason literal unions ─────────────────────────────────────────────── +// Closed sets so typos at call sites are rejected at compile time and can't +// silently fragment the telemetry buckets produced by summarizeWorktreeTelemetry. + +export type WorktreeCreatedReason = "create-milestone" | "enter-milestone"; +export type AutoExitReason = + | "pause" + | "stop" + | "blocked" + | "merge-conflict" + | "merge-failed" + | "slice-merge-conflict" + | "all-complete" + | "no-active-milestone" + | "other"; + +// ─── Emitters ──────────────────────────────────────────────────────────── + +export function emitWorktreeCreated( + projectRoot: string, + milestoneId: string, + meta: { flowId?: string; reason?: WorktreeCreatedReason } = {}, +): void { + emitJournalEvent(projectRoot, baseEntry("worktree-created", { + milestoneId, + startedAt: now(), + flowId: meta.flowId, + reason: meta.reason ?? "enter-milestone", + })); +} + +export function emitWorktreeMerged( + projectRoot: string, + milestoneId: string, + meta: { + flowId?: string; + reason?: "milestone-complete" | "all-complete" | "stop-fallback" | "transition" | "other"; + startedAt?: string; + durationMs?: number; + sliceCount?: number; + taskCount?: number; + conflict?: boolean; + conflictedFiles?: number; + } = {}, +): void { + emitJournalEvent(projectRoot, baseEntry("worktree-merged", { + milestoneId, + endedAt: now(), + flowId: meta.flowId, + reason: meta.reason ?? "other", + startedAt: meta.startedAt, + durationMs: meta.durationMs, + sliceCount: meta.sliceCount, + taskCount: meta.taskCount, + conflict: meta.conflict ?? false, + conflictedFiles: meta.conflictedFiles ?? 0, + })); +} + +export function emitWorktreeOrphaned( + projectRoot: string, + milestoneId: string, + meta: { + flowId?: string; + reason: "in-progress-unmerged" | "complete-unmerged" | "stale-branch"; + commitsAhead?: number; + worktreeDirExists?: boolean; + }, +): void { + emitJournalEvent(projectRoot, baseEntry("worktree-orphaned", { + milestoneId, + flowId: meta.flowId, + reason: meta.reason, + commitsAhead: meta.commitsAhead, + worktreeDirExists: meta.worktreeDirExists ?? false, + detectedAt: now(), + })); +} + +export function emitAutoExit( + projectRoot: string, + meta: { + flowId?: string; + /** Must come from the closed AutoExitReason set. Callers with free-form + * reasons (e.g. stopAuto's `reason?: string` parameter) should map to + * the closed set before emitting. */ + reason: AutoExitReason; + milestoneId?: string; + milestoneMerged: boolean; + }, +): void { + emitJournalEvent(projectRoot, baseEntry("auto-exit", { + reason: meta.reason, + flowId: meta.flowId, + milestoneId: meta.milestoneId, + milestoneMerged: meta.milestoneMerged, + exitedAt: now(), + })); +} + +export function emitWorktreeSync( + projectRoot: string, + milestoneId: string, + meta: { + flowId?: string; + filesCopied?: number; + bytesCopied?: number; + commitsAhead?: number; + worktreeAgeMs?: number; + }, +): void { + emitJournalEvent(projectRoot, baseEntry("worktree-sync", { + milestoneId, + flowId: meta.flowId, + filesCopied: meta.filesCopied, + bytesCopied: meta.bytesCopied, + commitsAhead: meta.commitsAhead, + worktreeAgeMs: meta.worktreeAgeMs, + })); +} + +export function emitCanonicalRootRedirect( + projectRoot: string, + milestoneId: string, + redirectedTo: string, + meta: { flowId?: string } = {}, +): void { + emitJournalEvent(projectRoot, baseEntry("canonical-root-redirect", { + milestoneId, + redirectedTo, + flowId: meta.flowId, + })); +} + +// #4765 — slice-cadence collapse events + +export function emitSliceMerged( + projectRoot: string, + milestoneId: string, + sliceId: string, + meta: { durationMs?: number; conflict?: boolean; commitSha?: string; flowId?: string } = {}, +): void { + emitJournalEvent(projectRoot, baseEntry("slice-merged", { + milestoneId, + sliceId, + mergedAt: now(), + durationMs: meta.durationMs, + conflict: meta.conflict ?? false, + commitSha: meta.commitSha, + flowId: meta.flowId, + })); +} + +export function emitMilestoneResquash( + projectRoot: string, + milestoneId: string, + meta: { sliceCount: number; startSha?: string; endSha?: string; flowId?: string } = { sliceCount: 0 }, +): void { + emitJournalEvent(projectRoot, baseEntry("milestone-resquash", { + milestoneId, + sliceCount: meta.sliceCount, + startSha: meta.startSha, + endSha: meta.endSha, + resquashedAt: now(), + flowId: meta.flowId, + })); +} + +// ─── Aggregator ────────────────────────────────────────────────────────── + +export interface WorktreeTelemetrySummary { + /** Count of worktrees created within the window */ + worktreesCreated: number; + /** Count of worktrees merged within the window */ + worktreesMerged: number; + /** Count of orphan detections within the window */ + orphansDetected: number; + /** Breakdown by orphan reason */ + orphansByReason: Record; + /** Merge durations in milliseconds, sorted ascending */ + mergeDurationsMs: number[]; + /** Number of merges that hit a conflict */ + mergeConflicts: number; + /** Auto-exit reasons and their counts */ + exitsByReason: Record; + /** Auto-exits where the milestone was NOT merged before exit — the #4761 producer metric */ + exitsWithUnmergedWork: number; + /** Count of canonical-root-redirects (how often #4761 validation would have read stale state) */ + canonicalRedirects: number; + /** #4765 — count of successful slice-level merges (slice-cadence feature) */ + slicesMerged: number; + /** #4765 — count of slice-level merge conflicts */ + sliceMergeConflicts: number; + /** #4765 — count of milestone-level re-squash operations */ + milestoneResquashes: number; +} + +/** + * Summarize worktree telemetry across the journal. Optional time window + * via filters.after / filters.before (ISO-8601). + */ +export function summarizeWorktreeTelemetry( + projectRoot: string, + filters?: { after?: string; before?: string }, +): WorktreeTelemetrySummary { + const entries = queryJournal(projectRoot, filters); + + const summary: WorktreeTelemetrySummary = { + worktreesCreated: 0, + worktreesMerged: 0, + orphansDetected: 0, + orphansByReason: {}, + mergeDurationsMs: [], + mergeConflicts: 0, + exitsByReason: {}, + exitsWithUnmergedWork: 0, + canonicalRedirects: 0, + slicesMerged: 0, + sliceMergeConflicts: 0, + milestoneResquashes: 0, + }; + + for (const e of entries) { + const d = e.data ?? {}; + switch (e.eventType) { + case "worktree-created": + summary.worktreesCreated++; + break; + case "worktree-merged": + summary.worktreesMerged++; + if (typeof d.durationMs === "number") summary.mergeDurationsMs.push(d.durationMs); + if (d.conflict === true) summary.mergeConflicts++; + break; + case "worktree-orphaned": { + summary.orphansDetected++; + const reason = typeof d.reason === "string" ? d.reason : "unknown"; + summary.orphansByReason[reason] = (summary.orphansByReason[reason] ?? 0) + 1; + break; + } + case "auto-exit": { + const reason = typeof d.reason === "string" ? d.reason : "unknown"; + summary.exitsByReason[reason] = (summary.exitsByReason[reason] ?? 0) + 1; + if (d.milestoneMerged === false) summary.exitsWithUnmergedWork++; + break; + } + case "canonical-root-redirect": + summary.canonicalRedirects++; + break; + case "slice-merged": + summary.slicesMerged++; + if (d.conflict === true) summary.sliceMergeConflicts++; + break; + case "milestone-resquash": + summary.milestoneResquashes++; + break; + default: + break; + } + } + + summary.mergeDurationsMs.sort((a, b) => a - b); + return summary; +} + +/** + * Return the p{quantile} of a sorted array using the nearest-rank method. + * Quantile in [0,1]. + * + * Prior implementation used Math.floor(q*n), which overstates exact-rank + * quantiles by one sample (e.g. p95 of 20 values returned the max instead + * of the 19th value). The nearest-rank index is ceil(q*n) - 1, clamped to + * [0, n-1]. + */ +export function percentile(sortedValues: number[], q: number): number | null { + if (sortedValues.length === 0) return null; + if (q <= 0) return sortedValues[0]; + if (q >= 1) return sortedValues[sortedValues.length - 1]; + const idx = Math.min( + sortedValues.length - 1, + Math.max(0, Math.ceil(q * sortedValues.length) - 1), + ); + return sortedValues[idx]; +}