sf: unify milestone discuss dispatch + todo.md seed injection

Replace separate dispatchHeadlessBootstrap with one flow:
- dispatchNewMilestoneDiscuss({ auto }) — auto=true uses headless
  prompt + rootFiles seed, no pendingAutoStartMap; auto=false uses
  discuss prompt with preparation, sets pendingAutoStartMap
- bootstrapNewMilestone() — project setup + ID reservation, called
  directly from bootstrapAutoSession instead of the old wrapper
- injectTodoContext() — reads and deletes todo.md/TODO.md/SPEC.md at
  project root, injects content as spec into any preamble; called
  identically in auto and interactive flows

Removes dispatchHeadlessBootstrap entirely. auto-start.ts now calls
the primitives directly. All three showWorkflowEntry new-milestone
sites use dispatchNewMilestoneDiscuss({ auto: false }).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikael Hugo 2026-04-19 19:04:12 +02:00
parent 67d25f95f2
commit c940ebc16f
2 changed files with 106 additions and 69 deletions

View file

@ -550,8 +550,12 @@ export async function bootstrapAutoSession(
// Auto mode: autonomously map the codebase and create milestones
// without waiting for user answers. Uses discuss-headless prompt.
ctx.ui.notify("No milestones found. Bootstrapping from codebase analysis.", "info");
const { dispatchHeadlessBootstrap } = await import("./guided-flow.js");
await dispatchHeadlessBootstrap(ctx, pi, base);
const { bootstrapNewMilestone, dispatchNewMilestoneDiscuss, injectTodoContext } = await import("./guided-flow.js");
const nextId = bootstrapNewMilestone(base);
await dispatchNewMilestoneDiscuss(ctx, pi, base, nextId, {
auto: true,
preamble: injectTodoContext(base, "This is an autonomous session."),
});
invalidateAllCaches();
const postState = await deriveState(base);
@ -593,8 +597,12 @@ export async function bootstrapAutoSession(
const hasContext = !!(contextFile && (await loadFile(contextFile)));
if (!hasContext) {
ctx.ui.notify(`Milestone ${mid} has no context. Bootstrapping from codebase analysis.`, "info");
const { dispatchHeadlessBootstrap } = await import("./guided-flow.js");
await dispatchHeadlessBootstrap(ctx, pi, base);
const { bootstrapNewMilestone, dispatchNewMilestoneDiscuss, injectTodoContext } = await import("./guided-flow.js");
const nextId = bootstrapNewMilestone(base);
await dispatchNewMilestoneDiscuss(ctx, pi, base, nextId, {
auto: true,
preamble: injectTodoContext(base, "This is an autonomous session."),
});
invalidateAllCaches();
const postState = await deriveState(base);

View file

@ -73,6 +73,30 @@ export {
} from "./guided-flow-queue.js";
import { logWarning } from "./workflow-logger.js";
// ─── Todo/Spec File Detection ────────────────────────────────────────────────
const TODO_FILE_NAMES = ["todo.md", "TODO.md", "SPEC.md", "spec.md"];
/**
* If a todo/spec file exists at the project root, read and delete it, then
* append its contents to `preamble` so any discuss or bootstrap prompt treats
* it as the primary specification. Returns the (possibly enriched) preamble.
*
* Called identically in auto-mode bootstrap and interactive discuss one flow.
*/
export function injectTodoContext(basePath: string, preamble: string): string {
for (const fname of TODO_FILE_NAMES) {
const fpath = join(basePath, fname);
if (!existsSync(fpath)) continue;
try {
const content = readFileSync(fpath, "utf-8").slice(0, 8000);
try { unlinkSync(fpath); } catch { /* non-fatal */ }
return `${preamble}\n\n### ${fname} (user-provided specification)\n\n${content}`;
} catch { /* non-fatal — fall through */ }
}
return preamble;
}
// ─── ID Generation with Reservation ─────────────────────────────────────────
/**
@ -556,7 +580,7 @@ async function prepareAndBuildDiscussPrompt(
* Bootstrap a .sf/ project from scratch for headless use.
* Ensures git repo, .sf/ structure, gitignore, and preferences all exist.
*/
function bootstrapProject(basePath: string): void {
export function bootstrapProject(basePath: string): void {
if (!nativeIsRepo(basePath) || isInheritedRepo(basePath)) {
const mainBranch = loadEffectiveSFPreferences()?.preferences?.git?.main_branch || "main";
nativeInit(basePath, mainBranch);
@ -609,63 +633,69 @@ export async function showHeadlessMilestoneCreation(
/**
* Headless auto-mode bootstrap: dispatch the discuss-headless unit without
* triggering pendingAutoStartMap (caller is already inside bootstrapAutoSession).
*
* Seeds the prompt by auto-reading README.md / package.json / go.mod from the
* project root so the model can map the codebase and plan autonomously.
* Single discuss-dispatch entry point for new milestones.
* auto=true headless prompt, rootFiles seed, plan-milestone workflow, no pendingAutoStartMap
* auto=false discuss prompt with preparation, discuss-milestone workflow, sets pendingAutoStartMap
*/
export async function dispatchHeadlessBootstrap(
export async function dispatchNewMilestoneDiscuss(
ctx: ExtensionCommandContext,
pi: ExtensionAPI,
basePath: string,
): Promise<string> {
nextId: string,
options: { auto: boolean; preamble: string; step?: boolean },
): Promise<void> {
if (options.auto) {
const seedParts: string[] = [options.preamble, ""];
const rootFiles = ["README.md", "README.rst", "package.json", "go.mod", "Cargo.toml", "pyproject.toml"];
for (const fname of rootFiles) {
try {
const fpath = join(basePath, fname);
if (existsSync(fpath)) {
const content = readFileSync(fpath, "utf-8").slice(0, 4000);
seedParts.push(`### ${fname}\n\n${content}`);
}
} catch { /* non-fatal */ }
}
seedParts.push(
"",
[
"Autonomously analyze this codebase to plan what needs to be built or improved.",
"",
"Investigation approach:",
"1. Scout the codebase deeply — use rg, find, ast-grep, and file reads to understand structure, patterns, and tech stack",
"2. Run existing tests (go test, cargo test, npm test, etc.) to measure current quality",
"3. Web search for industry best practices for this type of software — testing strategies, architecture patterns, operational requirements",
"4. Research any libraries, frameworks, or external services involved — get current API docs and constraints",
"5. Identify gaps: missing tests, incomplete features, error handling, observability, security, documentation",
"",
"Goal: define milestones that represent the highest-value work to make this software production-ready, well-tested, and complete.",
"Use all available models and research tools. Treat your findings as the specification.",
].join("\n"),
);
const prompt = buildHeadlessDiscussPrompt(nextId, seedParts.join("\n"), basePath);
// Do NOT set pendingAutoStartMap — caller (bootstrapAutoSession) manages the loop
await dispatchWorkflow(pi, prompt, "sf-run", ctx, "plan-milestone");
} else {
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: options.step, createdAt: Date.now() });
const prompt = await prepareAndBuildDiscussPrompt(ctx, pi, nextId, options.preamble, basePath);
await dispatchWorkflow(pi, prompt, "sf-run", ctx, "discuss-milestone");
}
}
/**
* Bootstrap a new milestone: ensure .sf/ structure exists, clear stale
* reservations, generate + reserve the next milestone ID, and create its
* slice directory. Returns the reserved ID.
*
* Call this before `dispatchNewMilestoneDiscuss` when starting from auto-start.
*/
export function bootstrapNewMilestone(basePath: string): string {
clearReservedMilestoneIds();
bootstrapProject(basePath);
const existingIds = findMilestoneIds(basePath);
const prefs = loadEffectiveSFPreferences();
const nextId = nextMilestoneIdReserved(existingIds, prefs?.preferences?.unique_milestone_ids ?? false);
const milestoneDir = join(sfRoot(basePath), "milestones", nextId, "slices");
mkdirSync(milestoneDir, { recursive: true });
// Build seed context from project root files
const seedParts: string[] = [
"This is an autonomous headless session. No specification document was provided.",
"",
];
const rootFiles = ["README.md", "README.rst", "package.json", "go.mod", "Cargo.toml", "pyproject.toml"];
for (const fname of rootFiles) {
try {
const fpath = join(basePath, fname);
if (existsSync(fpath)) {
const content = readFileSync(fpath, "utf-8").slice(0, 4000);
seedParts.push(`### ${fname}\n\n${content}`);
}
} catch { /* non-fatal */ }
}
seedParts.push(
"",
[
"Autonomously analyze this codebase to plan what needs to be built or improved.",
"",
"Investigation approach:",
"1. Scout the codebase deeply — use rg, find, ast-grep, and file reads to understand structure, patterns, and tech stack",
"2. Run existing tests (go test, cargo test, npm test, etc.) to measure current quality",
"3. Web search for industry best practices for this type of software — testing strategies, architecture patterns, operational requirements",
"4. Research any libraries, frameworks, or external services involved — get current API docs and constraints",
"5. Identify gaps: missing tests, incomplete features, error handling, observability, security, documentation",
"",
"Goal: define milestones that represent the highest-value work to make this software production-ready, well-tested, and complete.",
"Use all available models and research tools. Treat your findings as the specification.",
].join("\n"),
);
const seedContext = seedParts.join("\n");
const prompt = buildHeadlessDiscussPrompt(nextId, seedContext, basePath);
// Do NOT set pendingAutoStartMap — caller (bootstrapAutoSession) handles the loop
await dispatchWorkflow(pi, prompt, "sf-run", ctx, "plan-milestone");
mkdirSync(join(sfRoot(basePath), "milestones", nextId, "slices"), { recursive: true });
return nextId;
}
@ -1465,12 +1495,11 @@ export async function showWorkflowEntry(
const isFirst = milestoneIds.length === 0;
if (isFirst) {
// First ever — skip wizard, just ask directly
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() });
await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, pi, nextId,
`New project, milestone ${nextId}. Do NOT read or explore .sf/ — it's empty scaffolding.`,
basePath
), "sf-run", ctx, "discuss-milestone");
await dispatchNewMilestoneDiscuss(ctx, pi, basePath, nextId, {
auto: false,
preamble: injectTodoContext(basePath, `New project, milestone ${nextId}. Do NOT read or explore .sf/ — it's empty scaffolding.`),
step: stepMode,
});
} else {
const choice = await showNextAction(ctx, {
title: "SF — Singularity Forge",
@ -1487,11 +1516,11 @@ export async function showWorkflowEntry(
});
if (choice === "new_milestone") {
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() });
await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, pi, nextId,
`New milestone ${nextId}.`,
basePath
), "sf-run", ctx, "discuss-milestone");
await dispatchNewMilestoneDiscuss(ctx, pi, basePath, nextId, {
auto: false,
preamble: injectTodoContext(basePath, `New milestone ${nextId}.`),
step: stepMode,
});
}
}
return;
@ -1526,11 +1555,11 @@ export async function showWorkflowEntry(
const uniqueMilestoneIds = !!loadEffectiveSFPreferences()?.preferences?.unique_milestone_ids;
const nextId = nextMilestoneIdReserved(milestoneIds, uniqueMilestoneIds);
pendingAutoStartMap.set(basePath, { ctx, pi, basePath, milestoneId: nextId, step: stepMode, createdAt: Date.now() });
await dispatchWorkflow(pi, await prepareAndBuildDiscussPrompt(ctx, pi, nextId,
`New milestone ${nextId}.`,
basePath
), "sf-run", ctx, "discuss-milestone");
await dispatchNewMilestoneDiscuss(ctx, pi, basePath, nextId, {
auto: false,
preamble: injectTodoContext(basePath, `New milestone ${nextId}.`),
step: stepMode,
});
} else if (choice === "status") {
const { fireStatusViaCommand } = await import("./commands.js");
await fireStatusViaCommand(ctx);