← back to The Ai Factory
src/stages/scaffold.js
104 lines
// Stage 3 — scaffold. qwen3:14b template-fills a Claude Code subagent .md or skill SKILL.md
// and writes it to a sandbox dir. Never writes to ~/.claude/{agents,skills}/ — that's stage 10.
const fs = require('node:fs/promises');
const path = require('node:path');
const { ollama } = require('../llm');
const SANDBOX_ROOT = path.join(__dirname, '..', '..', 'output');
const SUBAGENT_SYSTEM = `You write Claude Code subagent files. Output ONLY the raw file content, no code fences, no commentary.
Format:
---
name: <kebab-name from spec>
description: <one detailed paragraph that includes WHEN to use this subagent and what it does. Use "PROACTIVELY" if it should be invoked without explicit user request. 80-200 words.>
tools: <comma-separated tools the subagent needs: Read, Write, Edit, Bash, Grep, WebSearch, WebFetch — pick only what is needed>
model: sonnet
---
<system-prompt body in markdown. Sections to include:
## Your job (1 sentence)
## Inputs (what the orchestrator hands you)
## What to check / do (bullet list)
## Output format (exact structure of what you return)
## Out of scope (from spec.out_of_scope)
Keep it under 400 words total.>`;
const SKILL_SYSTEM = `You write Claude Code skill SKILL.md files. Output ONLY the raw file content, no code fences, no commentary.
Format:
---
name: <kebab-name from spec>
description: <one detailed paragraph including WHEN this skill should auto-trigger. Mention specific trigger phrases from spec.triggers. 60-180 words.>
---
# <Title from name>
<Skill body in markdown. Sections:
## When to use (bullet list of triggers)
## What it does (bullet list)
## How to invoke (concrete steps or commands)
## Out of scope (from spec.out_of_scope)
Keep under 350 words.>`;
async function runScaffold({ runId, spec }) {
const isSubagent = spec.artifact_type === 'subagent';
const system = isSubagent ? SUBAGENT_SYSTEM : SKILL_SYSTEM;
const userPrompt = `Spec:
${JSON.stringify(spec, null, 2)}
Write the file content now.`;
const content = await ollama({
model: 'qwen3:14b',
system,
prompt: userPrompt,
temperature: 0.2
});
const cleaned = stripFences(content).trim();
// SECURITY (P0 fix 2026-05-04): re-assert artifact_name validity + sandbox
// containment. Intake regex is the contract but a poisoned/prompt-injected
// qwen response could still emit a name that bypasses an upstream check.
if (!/^[a-z0-9-]{2,60}$/.test(String(spec?.artifact_name || ''))) {
throw new Error(`scaffold: invalid artifact_name: ${spec?.artifact_name}`);
}
const runDir = path.join(SANDBOX_ROOT, `run-${runId}`);
await fs.mkdir(runDir, { recursive: true });
const outPath = isSubagent
? path.join(runDir, `${spec.artifact_name}.md`)
: path.join(runDir, spec.artifact_name, 'SKILL.md');
const resolvedOut = path.resolve(outPath);
if (!resolvedOut.startsWith(path.resolve(SANDBOX_ROOT) + path.sep)) {
throw new Error(`scaffold path escaped sandbox: ${resolvedOut}`);
}
await fs.mkdir(path.dirname(outPath), { recursive: true });
await fs.writeFile(outPath, cleaned, 'utf8');
const errors = validateScaffold(cleaned, spec);
return { outPath, content: cleaned, errors };
}
function stripFences(s) {
// qwen3:14b sometimes wraps output in ```markdown fences despite instructions.
return s.replace(/^```(?:markdown|md)?\s*\n/, '').replace(/\n```\s*$/, '');
}
function validateScaffold(content, spec) {
const errors = [];
if (!/^---\s*\n/.test(content)) errors.push('missing YAML frontmatter opening ---');
if (!/\n---\s*\n/.test(content)) errors.push('missing YAML frontmatter closing ---');
if (!new RegExp(`name:\\s*${escapeRe(spec.artifact_name)}`).test(content)) {
errors.push(`frontmatter name does not match spec.artifact_name (${spec.artifact_name})`);
}
if (!/description:\s*\S/.test(content)) errors.push('frontmatter missing description');
return errors;
}
function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
module.exports = { runScaffold };