255 lines
8.1 KiB
JavaScript
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;
|
|
}
|