import fs from "node:fs"; import crypto from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; export const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); export const CONTEXT_LIMITS = Object.freeze({ queueBytes: 4096, taskBytes: 8192, parentManifestBytes: 24576, parentStatusBytes: 6144, evidenceFiles: 12, evidenceBytes: 8192, contextTokens: 3500, }); const read = (file) => fs.readFileSync(file, "utf8"); const json = (file) => JSON.parse(read(file)); const relative = (file) => path.relative(root, file).replaceAll(path.sep, "/"); const tokenEstimate = (value) => Math.ceil(Buffer.byteLength(value, "utf8") / 4); const queuePath = path.join(root, "docs/EXECUTION_QUEUE.md"); const planPath = path.join(root, "tests/golden/M15-03A/next-task-plan.json"); const taskIndexPath = path.join(root, "tests/golden/M15-03A/task-index.json"); const sha256File = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); let cachedTaskIndex; let taskIndexLoaded = false; const safeName = (value) => value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 96) || "gap"; const expandTask = (task) => ({ ...task, fixture: { path: `tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`, state: "REQUIRED" }, desktopCommand: `build_blender_5.2.0/bin/blender -b --factory-startup --python tools/web/generated/${task.id}.py -- tests/files/web/generated/${task.id}-${safeName(task.gapId)}.blend`, webCommand: `npm --prefix web run test:generated-gap -- --task ${task.id}`, comparator: `node tools/web/check-generated-gap.mjs --task ${task.id}`, exitCriteria: ["desktop fixture evidence exists", "WASM uses the same fixture", "comparator passes", "save/reopen preserves Main", "manifest hashes all artifacts"], }); export function parseQueue() { const source = read(queuePath); const current = source.match(/当前任务\s*[::]\s*`([^`]+)`/u)?.[1] ?? source.match(/\|\s*当前任务\s*\|\s*`([^`]+)`/u)?.[1]; const parentManifest = source.match(/parent manifest\s*[::]\s*`?([^`\n]+)`?/iu)?.[1]?.trim() ?? source.match(/\|\s*parent manifest\s*\|\s*`([^`]+)`/u)?.[1]; if (!current || !parentManifest) throw new Error("EXECUTION_QUEUE.md must declare current task and parent manifest"); return { path: queuePath, source, currentTask: current, parentManifest }; } function loadTaskIndex() { if (taskIndexLoaded) return cachedTaskIndex; taskIndexLoaded = true; if (!fs.existsSync(taskIndexPath)) { if (fs.existsSync(planPath)) throw new Error(`missing task index; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`); return null; } const index = json(taskIndexPath); if (index.schemaVersion !== 1 || !index.source?.path || !index.catalog?.path || !index.entries) { throw new Error(`invalid task index: ${relative(taskIndexPath)}`); } cachedTaskIndex = index; return cachedTaskIndex; } export function verifyTaskIndex() { const index = loadTaskIndex(); if (!index) return null; const sourcePath = path.join(root, index.source.path); const catalogPath = path.join(root, index.catalog.path); if (!fs.existsSync(sourcePath) || sha256File(sourcePath) !== index.source.sha256) { throw new Error(`task index is stale; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`); } if (!fs.existsSync(catalogPath) || sha256File(catalogPath) !== index.catalog.sha256) { throw new Error(`task catalog hash mismatch; run node tools/web/generate-task-index.mjs (${relative(taskIndexPath)})`); } return index; } function indexedEntry(task, index = loadTaskIndex()) { const metadata = index?.entries?.[task]; if (!metadata) return null; const catalogPath = path.join(root, index.catalog.path); if (!fs.existsSync(catalogPath)) throw new Error(`missing task catalog: ${relative(catalogPath)}`); const handle = fs.openSync(catalogPath, "r"); try { const buffer = Buffer.alloc(metadata.length); fs.readSync(handle, buffer, 0, metadata.length, metadata.offset); const entry = { ...expandTask(JSON.parse(buffer.toString("utf8"))), previous: metadata.previous, next: metadata.next }; if (entry.id !== task) throw new Error(`task catalog id mismatch for ${task}`); return entry; } finally { fs.closeSync(handle); } } export function readIndexedTask(task) { return indexedEntry(task); } function planEntry(task) { const indexed = indexedEntry(task); if (indexed) return indexed; return null; } function planParent(task) { const index = loadTaskIndex(); if (index?.entries?.[task]) return index.entries[task].previous; return null; } function cardFields(source) { const get = (name) => source.match(new RegExp(`^[-*]?\\s*\\x60?${name}\\x60?\\s*[::]\\s*\\x60?([^\\n\\x60]+)`, "imu"))?.[1]?.trim(); const task = get("task"); const parent = get("parent"); const status = get("status"); const gap = get("gap"); const ownerFamily = get("ownerFamily"); const targetImplementationClass = get("targetImplementationClass"); return { task, parent, status, gap, ownerFamily, targetImplementationClass }; } function commandLines(source) { const candidates = []; for (const line of source.split("\n")) { const clean = line.replaceAll("`", "").replace(/^\s*[-*]\s*/, "").trim(); if (clean === "bash") continue; if (/^(npm|node|cmake|ctest|bash|build_[A-Za-z0-9_./-]+\/bin\/blender)\b/u.test(clean)) candidates.push(clean); } return [...new Set(candidates)].slice(0, 8); } function inputPaths(source) { const paths = []; for (const match of source.matchAll(/`([^`]+)`/gu)) { const value = match[1].trim(); if (!value || /\s/u.test(value)) continue; if (/^(docs|tests|tools|web|blender-5\.2\.0|build_blender_5\.2\.0)\//u.test(value)) paths.push(value); } return [...new Set(paths)]; } function pathBytes(file, limit) { let stat; try { stat = fs.lstatSync(file); } catch (error) { if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return { bytes: null, reason: "MISSING_PATH" }; return { bytes: null, reason: "PATH_STAT_FAILED" }; } if (stat.isSymbolicLink()) return { bytes: null, reason: "SYMLINK_PATH" }; if (stat.isFile()) return { bytes: stat.size }; if (!stat.isDirectory()) return { bytes: null, reason: "UNREADABLE_PATH_TYPE" }; // Directory evidence is measured recursively, but stop as soon as it cannot fit. let bytes = 0; const stack = [file]; while (stack.length > 0) { const current = stack.pop(); let entries; try { entries = fs.readdirSync(current, { withFileTypes: true }); } catch { return { bytes: null, reason: "PATH_STAT_FAILED" }; } for (const entry of entries) { const child = path.join(current, entry.name); if (entry.isSymbolicLink()) return { bytes: null, reason: "SYMLINK_PATH" }; if (entry.isDirectory()) { stack.push(child); continue; } if (entry.isFile()) { try { bytes += fs.statSync(child).size; } catch { return { bytes: null, reason: "PATH_STAT_FAILED" }; } if (bytes > limit) return { bytes }; } } } return { bytes }; } const pathWithinRoot = (candidate) => { if (typeof candidate !== "string" || candidate.length === 0 || path.isAbsolute(candidate)) return false; const absolute = path.resolve(root, candidate); return absolute === root || absolute.startsWith(`${root}${path.sep}`); }; const normalizedPathKey = (candidate) => path.normalize(candidate).replace(/[\\/]+$/u, ""); export function selectInputPaths(candidates, { sourceBytes = 0, sourceTokens = 0, reservedPaths = [], generatedPaths = [], limits = {}, } = {}) { const effectiveLimits = { ...CONTEXT_LIMITS, ...limits }; const unique = [...new Set((candidates ?? []).filter((candidate) => typeof candidate === "string" && candidate.length > 0))]; const reserved = new Set(reservedPaths.map(normalizedPathKey)); const generated = new Set(generatedPaths.map(normalizedPathKey)); const contextRemainingTokens = Math.max(0, effectiveLimits.contextTokens - sourceTokens); const contextRemainingBytes = Math.max(0, effectiveLimits.contextTokens * 4 - sourceBytes); const excluded = []; const selected = []; let selectedBytes = 0; let selectedTokens = 0; for (const candidate of unique) { const normalized = normalizedPathKey(candidate); if (generated.has(normalized)) { excluded.push({ path: candidate, bytes: null, reason: "GENERATED_EVIDENCE_OUTPUT" }); continue; } if (reserved.has(normalized)) { excluded.push({ path: candidate, bytes: 0, reason: "ALREADY_IN_CONTEXT" }); continue; } if (!pathWithinRoot(candidate)) { excluded.push({ path: candidate, bytes: null, reason: "PATH_OUTSIDE_REPOSITORY" }); continue; } const absolute = path.join(root, candidate); const measured = pathBytes(absolute, effectiveLimits.evidenceBytes); if (measured.reason) { excluded.push({ path: candidate, bytes: measured.bytes, reason: measured.reason }); continue; } const bytes = measured.bytes; const tokens = Math.ceil(bytes / 4); if (selected.length >= effectiveLimits.evidenceFiles) { excluded.push({ path: candidate, bytes, reason: "EVIDENCE_FILE_COUNT" }); continue; } if (bytes > effectiveLimits.evidenceBytes || selectedBytes + bytes > effectiveLimits.evidenceBytes) { excluded.push({ path: candidate, bytes, reason: "EVIDENCE_BYTE_BUDGET" }); continue; } if (bytes > contextRemainingBytes || selectedBytes + bytes > contextRemainingBytes || tokens > contextRemainingTokens || selectedTokens + tokens > contextRemainingTokens) { excluded.push({ path: candidate, bytes, reason: "CONTEXT_REMAINING_SPACE" }); continue; } selected.push({ path: candidate, bytes, tokens }); selectedBytes += bytes; selectedTokens += tokens; } return { inputPaths: selected.map(({ path: candidate }) => candidate), inputSelection: { schemaVersion: 1, limits: { maxFiles: effectiveLimits.evidenceFiles, evidenceBytes: effectiveLimits.evidenceBytes, contextRemainingBytes, contextRemainingTokens, }, source: { bytes: sourceBytes, tokens: sourceTokens }, selected, excluded, totals: { files: selected.length, bytes: selectedBytes, tokens: selectedTokens }, }, }; } function nextFromPlan(task) { const index = loadTaskIndex(); if (index?.entries?.[task]) return index.entries[task].next; return null; } export function buildTaskContext(requestedTask) { verifyTaskIndex(); const queue = parseQueue(); const task = requestedTask ?? queue.currentTask; const taskPath = path.join(root, "docs/tasks", `${task}.md`); const entry = planEntry(task); const taskExists = fs.existsSync(taskPath); const taskSource = taskExists ? read(taskPath) : ""; const fields = taskExists ? cardFields(taskSource) : {}; if (taskExists && fields.task !== task) throw new Error(`task card id mismatch: ${relative(taskPath)}`); const parent = fields.parent ?? (task === queue.currentTask ? path.basename(path.dirname(queue.parentManifest)) : planParent(task)); if (!parent) throw new Error(`task ${task} has no parent; add a compact task card or plan parent`); const parentManifestPath = path.join(root, "tests/golden", parent, "manifest.json"); if (!fs.existsSync(parentManifestPath)) throw new Error(`missing parent manifest: ${relative(parentManifestPath)}`); const parentManifestSource = read(parentManifestPath); const parentManifest = JSON.parse(parentManifestSource); if (parentManifest.nextTask !== task) throw new Error(`parent manifest nextTask=${parentManifest.nextTask} does not point to ${task}`); if (parentManifest.status && parentManifest.status !== "done" && !parentManifest.enablingTask) { throw new Error(`parent manifest status=${parentManifest.status} is not complete`); } const parentStatusPath = path.join(root, "docs/status", `${parent}.md`); const parentStatusSource = fs.existsSync(parentStatusPath) ? read(parentStatusPath) : ""; const ownManifestPath = path.join(root, "tests/golden", task, "manifest.json"); const ownManifest = fs.existsSync(ownManifestPath) ? json(ownManifestPath) : null; if (!requestedTask && task === queue.currentTask && ownManifest?.status === "done") { throw new Error(`queue current task ${task} is already done; advance EXECUTION_QUEUE.md to ${ownManifest.nextTask}`); } if (!requestedTask && task === queue.currentTask && path.basename(path.dirname(queue.parentManifest)) !== parent) { throw new Error(`queue parent manifest does not match task card parent ${parent}`); } const goal = taskExists ? (taskSource.match(/^##\s+目标\s*\n([\s\S]*?)(?=^##\s|$)/mu)?.[1].trim() ?? taskSource.match(/^#\s+[^\n::]+[::]\s*(.+)$/mu)?.[1].trim() ?? taskSource.match(/^#\s+[^\n]+\n\n([^\n]+)/u)?.[1].trim() ?? `完成 ${entry?.gapId ?? task} 的最小可观察切片`) : `完成 ${entry?.gapId ?? task} 的最小可观察切片`; const commands = [...commandLines(taskSource), ...(entry ? [entry.desktopCommand, entry.webCommand, entry.comparator] : [])].filter(Boolean).filter((value, index, values) => values.indexOf(value) === index).slice(0, 8); const taskBytes = Buffer.byteLength(taskSource, "utf8"); const taskInputPaths = taskExists ? inputPaths(taskSource) : []; const candidatePaths = taskExists && taskInputPaths.length > 0 ? taskInputPaths : [entry?.fixture?.path, entry?.comparator && "tools/web/check-generated-gap.mjs"].filter(Boolean); const contextSources = [queue.source, taskSource, parentManifestSource, parentStatusSource]; const sourceBytes = contextSources.reduce((sum, source) => sum + Buffer.byteLength(source, "utf8"), 0); const sourceTokens = contextSources.reduce((sum, source) => sum + tokenEstimate(source), 0); const reservedPaths = [relative(queuePath), relative(taskPath), relative(parentManifestPath), relative(parentStatusPath)]; const ownStatusPath = path.join(root, "docs/status", `${task}.md`); const ownOutputDir = path.join(root, "tests/golden", task); const generatedPaths = [relative(ownOutputDir), relative(ownManifestPath), relative(ownStatusPath)]; const selectedInputs = selectInputPaths(candidatePaths, { sourceBytes, sourceTokens, reservedPaths, generatedPaths }); const nextTask = ownManifest?.nextTask ?? nextFromPlan(task) ?? null; const context = { schemaVersion: 1, task, parentTask: parent, status: fields.status ?? entry?.state ?? "pending", goal, scope: { gap: fields.gap ?? entry?.gapId ?? "NOT_APPLICABLE", ownerFamily: fields.ownerFamily ?? entry?.ownerFamily ?? "NOT_APPLICABLE", implementationClass: fields.targetImplementationClass ?? entry?.targetImplementationClass ?? "NOT_APPLICABLE", }, commands, inputPaths: selectedInputs.inputPaths, inputSelection: selectedInputs.inputSelection, exitCriteria: entry?.exitCriteria ?? ["focused command exits 0", "report and manifest are hash-bound", "failure path does not publish a late result"], nextTask, sourceDocuments: { queue: relative(queuePath), taskCard: taskExists ? relative(taskPath) : "TASK_INDEX_ENTRY", taskIndex: relative(taskIndexPath), parentManifest: relative(parentManifestPath), parentStatus: fs.existsSync(parentStatusPath) ? relative(parentStatusPath) : "NOT_APPLICABLE", }, readPolicy: { required: [relative(queuePath), taskExists ? relative(taskPath) : "TASK_INDEX_ENTRY", relative(parentManifestPath), fs.existsSync(parentStatusPath) ? relative(parentStatusPath) : "NOT_APPLICABLE"], machineOnly: [relative(taskIndexPath), "tests/golden/M15-03A/task-catalog.jsonl"], optionalByNeed: ["docs/WEB_BLENDER_MODELER_V1_SCOPE.md", "任务卡列出的精确协议/生产路径", "任务卡明确命名的长期计划小节"], forbiddenByDefault: ["完整 CURRENT_EXECUTION_PLAN.md", "完整 PROJECT_STATUS_AND_NEXT_WORK.md", "全部 docs/status/*.md", "完整 parity/WBS 计划", "README 历史和 test-results/"], }, parent: { manifest: { schemaVersion: parentManifest.schemaVersion, task: parentManifest.task, parentTask: parentManifest.parentTask, status: parentManifest.status ?? "done", nextTask: parentManifest.nextTask, artifactCount: Object.keys(parentManifest.artifacts ?? {}).length, }, statusSummary: parentStatusSource.split("\n").filter(Boolean).slice(0, 8), }, budgets: { ...CONTEXT_LIMITS, taskBytes, taskLines: taskSource ? taskSource.split("\n").length : 0, taskTokens: tokenEstimate(taskSource) }, }; return { context, files: { queuePath, taskPath, parentManifestPath, parentStatusPath }, sources: { queue, taskSource, parentManifest, parentManifestSource, parentStatusSource } }; } export function contextSizeReport(bundle) { const { context, files, sources } = bundle; const measure = (file, source) => ({ path: relative(file), bytes: Buffer.byteLength(source, "utf8"), lines: source ? source.split("\n").length : 0, tokens: tokenEstimate(source) }); const docs = [measure(files.queuePath, sources.queue.source), measure(files.taskPath, sources.taskSource), measure(files.parentManifestPath, sources.parentManifestSource), measure(files.parentStatusPath, sources.parentStatusSource)]; const sourceTokens = docs.reduce((sum, item) => sum + item.tokens, 0); const evidenceBytes = context?.inputSelection?.totals?.bytes ?? 0; const evidenceFiles = context?.inputSelection?.totals?.files ?? 0; const evidenceTokens = context?.inputSelection?.totals?.tokens ?? 0; const totalTokens = sourceTokens + evidenceTokens; return { documents: docs, sourceTokens, evidenceFiles, evidenceBytes, evidenceTokens, totalTokens, withinBudget: docs[0].bytes <= CONTEXT_LIMITS.queueBytes && docs[1].bytes <= CONTEXT_LIMITS.taskBytes && docs[2].bytes <= CONTEXT_LIMITS.parentManifestBytes && docs[3].bytes <= CONTEXT_LIMITS.parentStatusBytes && evidenceFiles <= CONTEXT_LIMITS.evidenceFiles && evidenceBytes <= CONTEXT_LIMITS.evidenceBytes && totalTokens <= CONTEXT_LIMITS.contextTokens }; }