singularity-forge/src/headless-context.ts

437 lines
13 KiB
TypeScript

/**
* Headless Context Loading — stdin reading, file context, and project bootstrapping
*
* Handles loading context from files or stdin for headless new-milestone,
* and bootstraps the .sf/ directory structure when needed.
*/
import {
type Dirent,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
statSync,
writeFileSync,
} from "node:fs";
import { join, relative, resolve } from "node:path";
import {
ensureGitignore,
ensurePreferences,
untrackRuntimeFiles,
} from "./resources/extensions/sf/gitignore.js";
import { ensureAgenticDocsScaffold } from "./resources/extensions/sf/agentic-docs-scaffold.js";
import { checkDocsScaffold, formatDocCheckReport } from "./resources/extensions/sf/doc-checker.js";
import { ensureSiftIndexWarmup } from "./resources/extensions/sf/code-intelligence.js";
import {
nativeInit,
nativeIsRepo,
} from "./resources/extensions/sf/native-git-bridge.js";
import { sfRoot } from "./resources/extensions/sf/paths.js";
import { isInheritedRepo } from "./resources/extensions/sf/repo-identity.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface ContextOptions {
context?: string; // file path or '-' for stdin
contextText?: string; // inline text
}
const AUTO_BOOTSTRAP_MAX_BYTES = 180_000;
const AUTO_BOOTSTRAP_MAX_FILE_BYTES = 40_000;
const AUTO_BOOTSTRAP_ROOT_FILES = [
"TODO.md",
"SPEC.md",
"VISION.md",
"PURPOSE.md",
"MISSION.md",
"ROADMAP.md",
"ARCHITECTURE.md",
"BUILD_PLAN.md",
"README.md",
"AGENTS.md",
"CLAUDE.md",
"CONTRIBUTING.md",
];
const AUTO_BOOTSTRAP_SOURCE_EXTENSIONS = new Set([
".go",
".ts",
".tsx",
".js",
".jsx",
".mjs",
".cjs",
".py",
".rs",
".java",
".kt",
".kts",
".rb",
".php",
".cs",
".c",
".cc",
".cpp",
".h",
".hpp",
".swift",
".scala",
".sh",
".bash",
".zsh",
".fish",
".sql",
".yaml",
".yml",
".toml",
".json",
".jsonc",
".xml",
".html",
".css",
".scss",
".sass",
".vue",
".svelte",
".lua",
".ex",
".exs",
".erl",
".hrl",
".clj",
".cljs",
".nix",
".proto",
]);
const AUTO_BOOTSTRAP_EXCLUDED_DIRS = new Set([
".git",
".sf",
"node_modules",
"vendor",
"dist",
"build",
"target",
".next",
".cache",
]);
// ---------------------------------------------------------------------------
// Stdin Reader
// ---------------------------------------------------------------------------
export async function readStdin(): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(chunk as Buffer);
}
return Buffer.concat(chunks).toString("utf-8");
}
// ---------------------------------------------------------------------------
// Context Loading
// ---------------------------------------------------------------------------
export async function loadContext(options: ContextOptions): Promise<string> {
if (options.contextText) return options.contextText;
if (options.context === "-") {
return readStdin();
}
if (options.context) {
return readFileSync(resolve(options.context), "utf-8");
}
throw new Error(
"No context provided. Use --context <file> or --context-text <text>",
);
}
export function hasMilestones(basePath: string): boolean {
const milestonesDir = join(basePath, ".sf", "milestones");
if (!existsSync(milestonesDir)) return false;
try {
return readdirSync(milestonesDir, { withFileTypes: true }).some((entry) =>
entry.isDirectory(),
);
} catch {
return false;
}
}
export function buildAutoBootstrapContext(basePath: string): string {
const selectedFiles = collectAutoBootstrapFiles(basePath);
const sourceFiles = collectSourceFiles(basePath);
const chunks: string[] = [
"# Autonomous Repo Bootstrap",
"",
"SF headless auto found no milestones. Use the repository files below as the seed context.",
"Research every relevant markdown document and every source file path before creating the initial milestone plan.",
"Use tool-based repository inspection for source contents; do not assume the seed excerpt is complete.",
"Extract the project purpose, vision, architecture, constraints, current TODOs, risks, eval/gate ideas, and implementation backlog.",
"Apply the ACE spec-first TDD shape when planning: purpose and consumer first, behavior contract before implementation, tests as specs, evidence after gates.",
"For each proposed slice, capture Observed/Inferred/Proposed facts, a falsifier, acceptance criteria, and the verification command or eval that proves it.",
"Use explorer-style subagents or equivalent high-context research passes before planning when the runtime supports them.",
"Recommended explorer passes: docs/purpose/vision; source architecture and dependency map; tests/gates/tooling; risks/backlog/eval candidates.",
"Merge explorer findings into one repo map with cited file paths before creating milestones.",
"Follow harness-engineering principles: keep AGENTS.md short as a table of contents, make docs/ the system of record, create versioned plans/evals, prefer mechanically enforced architecture/taste rules, and add cleanup/gardening work when repo knowledge is stale.",
"Optimize for agent legibility: every milestone should improve the next agent's ability to understand, validate, and safely modify the repo.",
"Create actionable milestones and slices from the repo's docs and source tree rather than asking the user to restate them.",
"",
];
let used = chunks.join("\n").length;
for (const filePath of selectedFiles) {
let content: string;
try {
content = readFileSync(filePath, "utf-8");
} catch {
continue;
}
if (content.length > AUTO_BOOTSTRAP_MAX_FILE_BYTES) {
content =
content.slice(0, AUTO_BOOTSTRAP_MAX_FILE_BYTES) +
"\n\n[truncated by SF headless auto bootstrap]\n";
}
const relPath = relative(basePath, filePath);
const block = `\n\n## ${relPath}\n\n${content.trim()}\n`;
if (used + block.length > AUTO_BOOTSTRAP_MAX_BYTES) break;
chunks.push(block);
used += block.length;
}
if (sourceFiles.length > 0) {
const inventoryLines = [
"\n\n## Source File Inventory\n",
"Inspect these source/config/test files during repo research before finalizing the plan.\n",
...sourceFiles.map((filePath) => `- ${relative(basePath, filePath)}`),
"",
];
const block = inventoryLines.join("\n");
if (used + block.length <= AUTO_BOOTSTRAP_MAX_BYTES) {
chunks.push(block);
used += block.length;
} else {
const remaining = AUTO_BOOTSTRAP_MAX_BYTES - used;
if (remaining > 1000) {
chunks.push(block.slice(0, remaining));
}
}
}
if (selectedFiles.length === 0) {
chunks.push(
"No markdown docs were found. Inspect the repository directly and create an initial milestone from source layout, package metadata, tests, and git status.",
);
}
return chunks.join("\n").trim() + "\n";
}
function collectAutoBootstrapFiles(basePath: string): string[] {
const seen = new Set<string>();
const files: string[] = [];
for (const name of AUTO_BOOTSTRAP_ROOT_FILES) {
const path = join(basePath, name);
if (existsMarkdownFile(path)) {
seen.add(path);
files.push(path);
}
}
for (const path of walkMarkdownFiles(basePath)) {
if (seen.has(path)) continue;
seen.add(path);
files.push(path);
}
return files;
}
function existsMarkdownFile(path: string): boolean {
try {
const stat = statSync(path);
return stat.isFile() && path.toLowerCase().endsWith(".md");
} catch {
return false;
}
}
function collectSourceFiles(basePath: string): string[] {
return walkFiles(basePath, (path) => {
const lower = path.toLowerCase();
if (lower.endsWith(".md")) return false;
const dot = lower.lastIndexOf(".");
return dot !== -1 && AUTO_BOOTSTRAP_SOURCE_EXTENSIONS.has(lower.slice(dot));
});
}
function walkMarkdownFiles(root: string): string[] {
return walkFiles(root, (path) => path.toLowerCase().endsWith(".md"));
}
function walkFiles(
root: string,
includeFile: (path: string) => boolean,
): string[] {
const found: string[] = [];
const visit = (dir: string) => {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true }) as Dirent[];
} catch {
return;
}
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
if (!AUTO_BOOTSTRAP_EXCLUDED_DIRS.has(entry.name)) {
visit(path);
}
continue;
}
if (entry.isFile() && includeFile(path)) {
found.push(path);
}
}
};
visit(root);
return found;
}
// ---------------------------------------------------------------------------
// Serena MCP Auto-Enrollment
// ---------------------------------------------------------------------------
/**
* Register the project in Serena's global config and add it to .sf/mcp.json.
* Called from bootstrapProject so every `sf init` or auto-bootstrap enrolls
* the repo in Serena MCP automatically — no extra flags needed.
*
* Uses `claude-code` context: disables tools SF already provides (read_file,
* execute_shell_command, etc.) so only Serena's unique symbol-level code
* intelligence tools are exposed via MCP.
*
* Availability check is deferred to first MCP connection — `uvx --from serena-agent`
* resolves lazily. If Serena is not installed, the MCP client will surface the
* error; the user can then run `uv tool install serena-agent`.
*/
function ensureSerenaMcp(basePath: string): void {
// 1. Register project path in ~/.serena/serena_config.yml (no-op if already present)
const serenaConfigPath = join(process.env.HOME ?? "/root", ".serena", "serena_config.yml");
const projectPath = resolve(basePath);
if (existsSync(serenaConfigPath)) {
const content = readFileSync(serenaConfigPath, "utf-8");
const lines = content.split("\n");
// Check if project already registered
const projectRe = /^(\s*)-\s*(.+)$/;
let inProjects = false;
let alreadyListed = false;
for (const line of lines) {
if (line.trim() === "projects:") {
inProjects = true;
} else if (inProjects) {
if (line.trim().startsWith("- ")) {
if (line.trim().slice(2).trim() === projectPath) {
alreadyListed = true;
}
} else if (!line.trim().startsWith("-") && line.trim() !== "" && !line.startsWith("#") && !line.startsWith(" ")) {
// End of projects list (next top-level key)
break;
}
}
}
if (!alreadyListed) {
// Find the projects: line and add our path after it
const newLines: string[] = [];
for (const line of lines) {
newLines.push(line);
if (line.trim() === "projects:") {
newLines.push(`- ${projectPath}`);
}
}
writeFileSync(serenaConfigPath, newLines.join("\n"), "utf-8");
}
} else {
// Create minimal global config with projects list
const serenaDir = join(serenaConfigPath, "..");
mkdirSync(serenaDir, { recursive: true });
writeFileSync(
serenaConfigPath,
`projects:\n- ${projectPath}\n`,
"utf-8",
);
}
// 2. Add/update serena MCP server in .sf/mcp.json
const sfDir = join(basePath, ".sf");
const mcpPath = join(sfDir, "mcp.json");
let mcpConfig: Record<string, unknown> = {};
if (existsSync(mcpPath)) {
try {
mcpConfig = JSON.parse(readFileSync(mcpPath, "utf-8"));
} catch {
// Corrupt JSON — overwrite below
}
}
// Avoid overwriting if already configured
const servers = (mcpConfig.mcpServers ?? mcpConfig.servers ?? {}) as Record<string, unknown>;
if (!servers["serena"]) {
servers["serena"] = {
command: "uvx",
args: [
"--from",
"serena-agent",
"serena",
"start-mcp-server",
"--transport",
"stdio",
"--project-from-cwd",
"--context",
"desktop-app",
],
};
mcpConfig.mcpServers = servers;
writeFileSync(mcpPath, JSON.stringify(mcpConfig, null, "\t") + "\n", "utf-8");
}
}
/**
* Bootstrap .sf/ directory structure for headless new-milestone.
* Mirrors the bootstrap logic from guided-flow.ts showSmartEntry().
* Auto-migrates legacy project state directories to .sf/ on first encounter.
*/
export function bootstrapProject(basePath: string): void {
const sfDir = join(basePath, ".sf");
const legacyDir = join(basePath, "." + ["g", "sd"].join(""));
if (!existsSync(sfDir) && existsSync(legacyDir)) {
renameSync(legacyDir, sfDir);
process.stderr.write(
"[headless] Migrated legacy project state to .sf/\n",
);
}
if (!nativeIsRepo(basePath) || isInheritedRepo(basePath)) {
nativeInit(basePath, "main");
}
const root = sfRoot(basePath);
mkdirSync(join(root, "milestones"), { recursive: true });
mkdirSync(join(root, "runtime"), { recursive: true });
ensureGitignore(basePath);
ensurePreferences(basePath);
ensureAgenticDocsScaffold(basePath);
ensureSiftIndexWarmup(basePath);
ensureSerenaMcp(basePath);
untrackRuntimeFiles(basePath);
// Run scaffold check after init — surfaces which files need real content
const report = checkDocsScaffold(basePath);
if (report.summary.stub > 0 || report.summary.missing > 0) {
process.stderr.write(`\n${formatDocCheckReport(report)}\n`);
}
}