port gsd2 #4769: worktree telemetry, slice-cadence, canonical-root fix + /sf scan

Ports commit 7fb35ca58 from gsd2 (PR #4769) covering four issues:

#4761 — resolveCanonicalMilestoneRoot in worktree-manager.ts routes
validate-milestone through the live worktree path instead of stale
project-root state when a milestone worktree is active.

#4762 — auditOrphanedMilestoneBranches in auto-start.ts now surfaces
in-progress milestone branches with unmerged commits ahead of main
(previously only complete milestones were audited). Gated on
isClosedStatus so parked/other closed statuses are unaffected.

#4764 — worktree-telemetry.ts: typed emit helpers (emitWorktreeCreated,
emitWorktreeMerged, emitWorktreeOrphaned, emitAutoExit, emitWorktreeSync,
emitCanonicalRootRedirect, emitSliceMerged, emitMilestoneResquash) plus
summarizeWorktreeTelemetry aggregator and nearest-rank percentile().
Wired in: worktree-resolver.ts (create/merge events), auto-start.ts
(orphan telemetry), auto.ts stopAuto (auto-exit with normalized reason),
worktree-manager.ts (canonical-root-redirect). Surfaced in forensics.ts
via detectWorktreeOrphans and Worktree Telemetry sections.

#4765 — slice-cadence.ts: mergeSliceToMain squash-merges each slice's
commits onto main as soon as the slice passes validation (opt-in via
git.collapse_cadence: "slice"). resquashMilestoneOnMain collapses N
per-slice commits into one milestone commit at completion. Wired in
auto-post-unit.ts (slice merge after complete-slice with stopAuto on
conflict/error) and worktree-resolver.ts (resquash at mergeAndExit).
AutoSession.milestoneStartShas tracks the pre-first-slice SHA.
GitPreferences and preferences-validation.ts extended with
collapse_cadence and milestone_resquash fields.

Also ports /sf scan command: commands-scan.ts with parseScanArgs,
resolveScanDocuments, buildScanOutputPaths, and handleScan dispatching
a focused codebase assessment prompt to .sf/codebase/.

journal.ts: 9 new JournalEventType values for the telemetry events.
All changes are additive; default behavior (cadence="milestone") unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-25 09:03:56 +02:00
parent 2911d3b93d
commit 12aabd863e
17 changed files with 1350 additions and 16 deletions

View file

@ -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 {

View file

@ -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)}`);
}
}
}

View file

@ -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

View file

@ -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<string, string> = 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;

View file

@ -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<FocusArea, string[]> = {
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 <basePath>/.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<void> {
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");
}
}

View file

@ -15,7 +15,7 @@ export interface GsdCommandDefinition {
type CompletionMap = Record<string, readonly GsdCommandDefinition[]>;
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" },

View file

@ -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);

View file

@ -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<ForensicRep
detectErrorTraces(unitTraces, anomalies);
detectJournalAnomalies(journalSummary, anomalies);
// 11b. #4764 — worktree lifecycle telemetry
let worktreeTelemetry: WorktreeTelemetrySummary | null = null;
try {
worktreeTelemetry = summarizeWorktreeTelemetry(basePath);
detectWorktreeOrphans(worktreeTelemetry, anomalies);
} catch {
// Telemetry is best-effort — do not let an aggregator failure block the
// rest of the forensic report.
}
return {
sfVersion,
timestamp: new Date().toISOString(),
@ -357,6 +370,7 @@ export async function buildForensicReport(basePath: string): Promise<ForensicRep
recentUnits,
journalSummary,
activityLogMeta,
worktreeTelemetry,
};
}
@ -890,6 +904,51 @@ function detectJournalAnomalies(journal: JournalSummary | null, anomalies: Foren
}
}
/**
* #4764 surface worktree lifecycle and orphan signals in the forensic report.
*
* Consumes only the aggregated summary (not raw journal events) to respect
* the forensics memory-bloat guard in forensics-journal.test.ts per-event
* detail stays in the journal itself where the LLM can query it on demand.
*/
function detectWorktreeOrphans(
summary: WorktreeTelemetrySummary,
anomalies: ForensicAnomaly[],
): void {
// 1. Orphan aggregate — severity depends on reason. In-progress orphans are
// the #4761 consumer-side signal (live work sitting on an unmerged branch).
for (const [reason, count] of Object.entries(summary.orphansByReason)) {
if (count <= 0) continue;
const severity: ForensicAnomaly["severity"] =
reason === "in-progress-unmerged" ? "warning" : "info";
anomalies.push({
type: "worktree-orphan",
severity,
summary: `${count} worktree orphan(s) detected (${reason})`,
details:
reason === "in-progress-unmerged"
? "Auto-mode exited without completing a milestone; live work sits on an unmerged milestone branch. Run `/sf auto` to resume, or merge manually."
: reason === "complete-unmerged"
? "A completed milestone's branch was never merged back to main. Run `/sf health --fix` to resolve."
: `Reason: ${reason}.`,
});
}
// 2. Auto-exit producer signal — #4761's upstream cause.
if (summary.exitsWithUnmergedWork > 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;

View file

@ -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_\-\/.]+$/;

View file

@ -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 {

View file

@ -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;
}

View file

@ -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}}

View file

@ -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/<MID>` 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;
}

View file

@ -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`);
}

View file

@ -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/<MID>/`
* (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: <path>". 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

View file

@ -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;
}
}

View file

@ -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<string, unknown>): 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<string, number>;
/** 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<string, number>;
/** 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];
}