← 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 };