singularity-forge/src/resources/extensions/sf/auto-bootstrap-context.js
2026-05-14 19:32:41 +02:00

255 lines
8.1 KiB
JavaScript

import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
const AUTO_BOOTSTRAP_MAX_BYTES = readPositiveIntEnv(
"SF_AUTO_BOOTSTRAP_MAX_BYTES",
48_000,
);
const AUTO_BOOTSTRAP_MAX_FILE_BYTES = readPositiveIntEnv(
"SF_AUTO_BOOTSTRAP_MAX_FILE_BYTES",
10_000,
);
const AUTO_BOOTSTRAP_MAX_INVENTORY_BYTES = readPositiveIntEnv(
"SF_AUTO_BOOTSTRAP_MAX_INVENTORY_BYTES",
12_000,
);
const AUTO_BOOTSTRAP_SF_SPEC_FILES = [
".sf/PROJECT.md",
".sf/REQUIREMENTS.md",
".sf/DECISIONS.md",
".sf/KNOWLEDGE.md",
".sf/RUNTIME.md",
".sf/STATE.md",
".sf/PRINCIPLES.md",
".sf/STYLE.md",
".sf/preferences.yaml",
".sf/NON-GOALS.md",
".sf/CODEBASE.md",
];
const AUTO_BOOTSTRAP_ORIENTATION_FILES = [
"TODO.md",
"README.md",
"AGENTS.md",
"CLAUDE.md",
"CONTRIBUTING.md",
];
const AUTO_BOOTSTRAP_PRIORITY_FILES = [
...AUTO_BOOTSTRAP_SF_SPEC_FILES,
...AUTO_BOOTSTRAP_ORIENTATION_FILES,
];
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",
]);
export function buildAutoBootstrapContext(basePath) {
const selectedFiles = collectAutoBootstrapFiles(basePath);
const sourceFiles = collectSourceFiles(basePath);
const chunks = [
"# Autonomous Repo Bootstrap",
"",
"SF headless autonomous found no milestones. Use the repository files below as the seed context.",
"Research SF working specs first, then 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.",
"Treat .sf/PROJECT.md, .sf/REQUIREMENTS.md, .sf/DECISIONS.md, .sf/KNOWLEDGE.md, and .sf/RUNTIME.md as SF's canonical working spec/state docs when present.",
"Treat any root-level SPEC.md, BASE_SPEC.md, PRODUCT_SPEC.md, docs/specs files, or other docs as repo evidence for humans. Project facts SF needs later into SF's .sf working model and DB-backed state; do not create a parallel base-spec system.",
"For product-facing or workflow-facing work, research the product category and representative competitors before locking requirements or slices. Capture table stakes, differentiators, common failure modes, and what not to copy.",
"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.",
"Every proposed milestone, slice, and task must state its purpose before implementation detail.",
"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, use docs/ for human exports and git-history reports, capture operational knowledge in .sf/DB-backed state, prefer mechanically enforced architecture/style 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;
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 autonomous 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)}`),
"",
];
let block = inventoryLines.join("\n");
if (block.length > AUTO_BOOTSTRAP_MAX_INVENTORY_BYTES) {
block =
block.slice(0, AUTO_BOOTSTRAP_MAX_INVENTORY_BYTES) +
"\n\n[truncated by SF headless autonomous bootstrap]\n";
}
if (used + block.length <= AUTO_BOOTSTRAP_MAX_BYTES) {
chunks.push(block);
} 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 readPositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (!raw) return fallback;
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function collectAutoBootstrapFiles(basePath) {
const seen = new Set();
const files = [];
for (const name of AUTO_BOOTSTRAP_PRIORITY_FILES) {
const path = join(basePath, name);
if (existsMarkdownFile(path)) {
seen.add(path);
files.push(path);
}
}
// Include .sf/repo-map/*.md pages — excluded from the general walk because
// .sf is in AUTO_BOOTSTRAP_EXCLUDED_DIRS, but repo-map pages are high-value
// generated orientation context that should always be available to new agents.
for (const path of collectRepoMapFiles(basePath)) {
if (!seen.has(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 collectRepoMapFiles(basePath) {
const repoMapDir = join(basePath, ".sf", "repo-map");
let entries;
try {
entries = readdirSync(repoMapDir, { withFileTypes: true });
} catch {
return [];
}
return entries
.filter((e) => e.isFile() && e.name.toLowerCase().endsWith(".md"))
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => join(repoMapDir, e.name));
}
function existsMarkdownFile(path) {
try {
const stat = statSync(path);
return stat.isFile() && path.toLowerCase().endsWith(".md");
} catch {
return false;
}
}
function collectSourceFiles(basePath) {
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) {
return walkFiles(root, (path) => path.toLowerCase().endsWith(".md"));
}
function walkFiles(root, includeFile) {
const found = [];
const visit = (dir) => {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} 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;
}