singularity-forge/src/headless-import-backlog.ts
Mikael Hugo 365c6bbc3b
Some checks are pending
CI / detect-changes (push) Waiting to run
CI / docs-check (push) Blocked by required conditions
CI / lint (push) Blocked by required conditions
CI / build (push) Blocked by required conditions
CI / integration-tests (push) Blocked by required conditions
CI / windows-portability (push) Blocked by required conditions
CI / rtk-portability (linux, blacksmith-4vcpu-ubuntu-2404) (push) Blocked by required conditions
CI / rtk-portability (macos, macos-15) (push) Blocked by required conditions
CI / rtk-portability (windows, blacksmith-4vcpu-windows-2025) (push) Blocked by required conditions
chore: formatter / linter touch-up (230 files)
Pure formatting / lint-fix pass that ran during `npm run build:core`
in the session that landed the agent-runner / quota / coverage /
phase-2 routing work. No logic changes — indentation, trailing
commas, import sort, etc. Captured separately so the actual feature
commits stay scoped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:19:53 +02:00

223 lines
5.7 KiB
TypeScript

/**
* headless-import-backlog.ts — deterministic markdown→SF-DB backlog importer.
*
* Parses a flat markdown file with H2 sections (## Title) into SF milestones,
* and bullet list items under each H2 into slices. No LLM, no RPC child.
* Writes directly to `.sf/sf.db` via the SF DB layer.
*
* Usage: sf headless import-backlog <file.md>
*/
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
export interface ImportBacklogOptions {
json: boolean;
}
interface ParsedSlice {
title: string;
status: string;
}
interface ParsedMilestone {
title: string;
slices: ParsedSlice[];
}
/**
* Convert a title string to a safe kebab-case slug for use as an SF ID.
* SF milestone IDs must match /^[a-z0-9]+(-[a-z0-9]+)*$/.
*/
function slugify(title: string): string {
return title
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/[\s-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
/**
* Parse a markdown backlog document into a list of milestones with slices.
*
* Sections are delimited by H2 headings (## Title). Bullet list items directly
* under a heading are collected as slices. Text paragraphs are ignored (they
* become part of the milestone vision if present).
*/
export function parseBacklogMarkdown(text: string): ParsedMilestone[] {
const milestones: ParsedMilestone[] = [];
let current: ParsedMilestone | null = null;
for (const rawLine of text.split("\n")) {
const line = rawLine.trimEnd();
const h2 = line.match(/^##\s+(.+)$/);
if (h2) {
current = { title: h2[1].trim(), slices: [] };
milestones.push(current);
continue;
}
if (!current) continue;
// Bullet items: -, *, +, or numbered (1. ...) — strip status emoji/markers
const bullet =
line.match(/^\s*[-*+]\s+(.+)$/) ?? line.match(/^\s*\d+\.\s+(.+)$/);
if (bullet) {
let title = bullet[1].trim();
// Strip leading status markers: ✅, 🟡, ⬜, ✓, x, [x], [ ], etc.
title = title.replace(/^[🟡x]\s+/u, "");
title = title.replace(/^\[[x ]\]\s+/i, "");
// Detect done status from emoji prefix in original
const isDone =
bullet[1].trim().startsWith("✅") ||
bullet[1].trim().match(/^\[x\]/i) != null;
if (title) {
current.slices.push({
title,
status: isDone ? "complete" : "pending",
});
}
}
}
return milestones.filter((m) => m.title.length > 0);
}
/**
* Run the import. Opens the SF DB, parses the backlog file, and upserts
* milestones + slices. Skips milestones whose slugged ID already exists.
*/
export async function runImportBacklog(
filePath: string,
cwd: string,
opts: ImportBacklogOptions,
): Promise<number> {
const log = opts.json
? () => {}
: (msg: string) => process.stderr.write(`[import-backlog] ${msg}\n`);
if (!existsSync(filePath)) {
process.stderr.write(
`[import-backlog] Error: file not found: ${filePath}\n`,
);
return 1;
}
const sfDir = join(cwd, ".sf");
if (!existsSync(sfDir)) {
process.stderr.write(
`[import-backlog] Error: no .sf directory found in ${cwd}\n` +
` Run 'sf headless init' first to bootstrap the project.\n`,
);
return 1;
}
// Open the SF database
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dynamicToolsPath =
"./resources/extensions/sf/bootstrap/dynamic-tools.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { ensureDbOpen } = (await import(dynamicToolsPath)) as any;
const opened = await ensureDbOpen(cwd);
if (!opened) {
process.stderr.write(
`[import-backlog] Error: could not open .sf/sf.db in ${cwd}\n`,
);
return 1;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sfDbPath = "./resources/extensions/sf/sf-db.js";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { insertMilestone, getMilestone, insertSlice, getAllMilestones } =
(await import(sfDbPath)) as any;
const text = readFileSync(filePath, "utf8");
const parsed = parseBacklogMarkdown(text);
if (parsed.length === 0) {
process.stderr.write(
`[import-backlog] No H2 sections found in ${filePath}\n` +
` Expected format: ## Section Title, with optional bullet items below.\n`,
);
return 1;
}
log(`Parsed ${parsed.length} milestone(s) from ${filePath}`);
// Determine the next sequence number to preserve import order
const existing = getAllMilestones();
let sequence = existing.length;
const results: {
id: string;
title: string;
slices: number;
skipped: boolean;
}[] = [];
for (const m of parsed) {
const id = slugify(m.title);
if (!id) {
log(`Skipping milestone with unslugifiable title: "${m.title}"`);
continue;
}
const exists = getMilestone(id) != null;
if (exists) {
log(`Skipping existing milestone: ${id}`);
results.push({ id, title: m.title, slices: 0, skipped: true });
continue;
}
insertMilestone({
id,
title: m.title,
status: "queued",
sequence: sequence++,
});
let sliceSeq = 0;
for (const s of m.slices) {
const sliceId = `s${String(++sliceSeq).padStart(2, "0")}`;
insertSlice({
milestoneId: id,
id: sliceId,
title: s.title,
status: s.status,
sequence: sliceSeq,
});
}
log(` + ${id}: "${m.title}" (${m.slices.length} slices)`);
results.push({
id,
title: m.title,
slices: m.slices.length,
skipped: false,
});
}
const imported = results.filter((r) => !r.skipped).length;
const skipped = results.filter((r) => r.skipped).length;
if (opts.json) {
process.stdout.write(
JSON.stringify({
schemaVersion: 1,
imported,
skipped,
milestones: results,
}) + "\n",
);
} else {
process.stderr.write(
`[import-backlog] Done: ${imported} imported, ${skipped} skipped.\n`,
);
}
return 0;
}