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:
parent
2911d3b93d
commit
12aabd863e
17 changed files with 1350 additions and 16 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
124
src/resources/extensions/sf/commands-scan.ts
Normal file
124
src/resources/extensions/sf/commands-scan.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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_\-\/.]+$/;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
79
src/resources/extensions/sf/prompts/scan.md
Normal file
79
src/resources/extensions/sf/prompts/scan.md
Normal 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}}
|
||||
299
src/resources/extensions/sf/slice-cadence.ts
Normal file
299
src/resources/extensions/sf/slice-cadence.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
322
src/resources/extensions/sf/worktree-telemetry.ts
Normal file
322
src/resources/extensions/sf/worktree-telemetry.ts
Normal 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];
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue