UNPKG

@blundergoat/goat-flow

Version:

AI coding agent harness and local dashboard for Claude Code, OpenAI Codex, Google Antigravity, and GitHub Copilot - setup audits, guardrails, structured skills, deny hooks, and persistent learning loops.

583 lines (505 loc) 19.7 kB
/** Scaffolds and validates `goat-flow skill new` skill/playbook drafts. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; import { getPackageVersion } from "./paths.js"; import { runCandidacyCheck, } from "./quality/candidacy.js"; import { findArtifact, scoreArtifact } from "./quality/skill-quality.js"; const WORKFLOW_TEMPLATE = `--- name: {{NAME}} description: "{{DESCRIPTION}}" goat-flow-skill-version: "{{VERSION}}" --- # /{{NAME}} ## Shared Conventions Always read \`.goat-flow/skill-docs/skill-preamble.md\` (Proof Gate, evidence discipline, mode system) and \`.goat-flow/skill-docs/skill-conventions.md\` before acting. ## When to Use Use when [describe the trigger condition for this skill]. **NOT this skill:** [list distinctly different intents that route elsewhere]. ## Read First [List the files / directories the skill must load before acting.] ## Step 0 - Intake State the intake context: - Goal: [one-line goal] - Mode: [Read-Only | File-Write - defaults to Read-Only] - Read first: [files this skill will load] ## Phase 1 - [Title] [Procedure for the first phase.] CHECKPOINT: [what stops execution before continuing to Phase 2]. ## Phase 2 - [Title] [Procedure for the second phase.] CHECKPOINT: [what stops execution before continuing]. ## Phase 3 - [Title] [Procedure for the third phase.] ## Verification Apply the Proof Gate from \`skill-preamble.md\` to every claim. Evidence required for every CONFIRMED finding. - [ ] [criterion 1] - [ ] [criterion 2] BLOCKING GATE: human approval required before [final action]. ## Modes - **Read-Only mode**: [describe what this skill does in read-only mode]. - **File-Write mode**: [describe; requires explicit mode confirmation and human approval]. Mode escalation requires explicit user approval before any write. `; const DISPATCHER_TEMPLATE = `--- name: {{NAME}} description: "{{DESCRIPTION}}" goat-flow-skill-version: "{{VERSION}}" --- # /{{NAME}} ## Shared Conventions Always read \`.goat-flow/skill-docs/skill-preamble.md\` (Proof Gate, evidence discipline) before routing. ## When to Use Use when the user's intent matches one of the routes below. This skill does not execute work itself; it dispatches to other skills. ## How It Works This skill is a router. It reads user intent, matches it against the route map, and dispatches to the appropriate sibling skill. No file writes happen at this layer - the dispatched skill owns its own gates and verification. ## Route Map | User intent | Route to | |---|---| | [intent A - describe] | [/skill-name-a] | | [intent B - describe] | [/skill-name-b] | | Unknown intent | Ask the user to clarify before dispatching | ## Read First Read \`skill-preamble.md\` for the Proof Gate the dispatched skill will apply. `; const REPORT_TEMPLATE = `--- name: {{NAME}} description: "{{DESCRIPTION}}" goat-flow-skill-version: "{{VERSION}}" --- # /{{NAME}} ## Shared Conventions Always read \`.goat-flow/skill-docs/skill-preamble.md\` (Proof Gate, evidence discipline) before scanning. ## When to Use Use when [describe the assessment trigger - audit, review, scan]. **NOT this skill:** [list distinctly different intents - for instance, this is reporting-only; if writes are required, route elsewhere]. ## Read First Read \`skill-preamble.md\` and any project-specific scope files before scanning. ## Quick Scan Path [Fast assessment for low-risk cases. Lists targets, surfaces obvious findings, exits with a summary.] ## Full Assessment Path [Deeper assessment for high-risk cases. Multi-phase scan with structured output.] ## Output Format Reports findings as structured markdown: \`\`\`markdown ## Findings - **CONFIRMED**: [finding] - evidence: [OBSERVED file + semantic anchor] - **SUSPECTED**: [finding] - evidence: [INFERRED reasoning] \`\`\` ## Constraints This skill is reporting-only. It must not write files or modify state. If a finding warrants action, route to the appropriate execution skill via the dispatcher. ## Verification Apply the Proof Gate from \`skill-preamble.md\`. Every CONFIRMED finding requires fresh evidence (OBSERVED tag with file + semantic anchor) re-read in the current session. - [ ] every finding has cited evidence - [ ] no fabricated or paraphrased claims BLOCKING GATE: human reviews findings before any action is taken. `; const PLAYBOOK_TEMPLATE = `--- goat-flow-reference-version: "{{VERSION}}" --- # {{NAME}} ## Purpose {{DESCRIPTION}} ## Availability Check \`\`\`bash command -v {{NAME}} || echo "{{NAME}} not installed; use the manual fallback below" \`\`\` If the tool is unavailable, use the [Fallback / Troubleshooting](#fallback--troubleshooting) section. ## Boundary - **Use when:** [describe the tool/capability situation this playbook handles]. - **Do not use when:** [name the adjacent skill, playbook, or instruction-file route]. - **Writes:** read-only guidance unless the workflow below names an explicit file-write action and verification gate. ## Workflow ### Step 1: [Action] \`\`\`bash [command] \`\`\` [What this step does and what to verify.] ### Step 2: [Verify] [How to confirm the action succeeded - what file appears, what output is expected.] ## Fallback / Troubleshooting If the tool is unavailable or fails: - **Alternative tool**: [describe the alternative] - **Manual approach**: [describe the manual procedure] - **Common errors**: [list likely failure modes and remedies] ## Verification Gate - [ ] Availability check result recorded, or non-runnable reference load condition stated. - [ ] Boundary still routes adjacent work to the right skill, playbook, instruction file, or CLI. - [ ] Workflow output has concrete pass/fail evidence. ## When to Load Skills load this playbook when [describe the trigger - e.g., when user evidence requires browser interaction]. `; const TEMPLATES_BY_SUBTYPE = { workflow: WORKFLOW_TEMPLATE, dispatcher: DISPATCHER_TEMPLATE, report: REPORT_TEMPLATE, playbook: PLAYBOOK_TEMPLATE, }; const SKILL_DIR = ".claude/skills"; const PLAYBOOK_DIR = ".goat-flow/skill-docs/playbooks"; /** User-facing validation error for invalid `skill new` mode combinations. */ class SkillNewInputError extends Error { /** Preserve the custom error name so the CLI can classify input failures. */ constructor(message) { super(message); this.name = "SkillNewInputError"; } } export { SkillNewInputError }; /** Replace scaffold placeholders after candidacy has selected a concrete artifact. */ function fillTemplate(template, vars) { return Object.entries(vars).reduce((acc, [key, value]) => acc.replaceAll(`{{${key}}}`, value), template); } function templateForRecommendation(recommendation) { if (recommendation.type === "skill") { return { templateKey: recommendation.subtype, isReference: false }; } if (recommendation.type === "reference") { if (recommendation.subtype === "playbook") { return { templateKey: "playbook", isReference: true }; } return null; } return null; } function resolveScaffold(projectRoot, name, recommendation) { const choice = templateForRecommendation(recommendation); if (!choice) return null; const template = TEMPLATES_BY_SUBTYPE[choice.templateKey]; if (!template) return null; // Forward-slash form so the path renders consistently in CLI/dashboard // output and matches assertion shapes; `node:fs` accepts both separators. const proposedPath = (choice.isReference ? join(projectRoot, PLAYBOOK_DIR, `${name}.md`) : join(projectRoot, SKILL_DIR, name, "SKILL.md")).replace(/\\/g, "/"); return { template, proposedPath, isReference: choice.isReference }; } /** Return the explicitly selected input modes so ambiguous invocations fail before prompting. */ function selectedInputModes(options) { const modes = []; if ((options.description ?? "").trim().length > 0) modes.push("description"); if ((options.draftPath ?? "").trim().length > 0) modes.push("--draft"); if (options.shouldUseInteractivePrompt) modes.push("--interactive"); return modes; } /** Throws on mixed modes because description, draft, and interactive flows branch early. */ function assertSingleInputMode(options) { const modes = selectedInputModes(options); if (modes.length <= 1) return; throw new SkillNewInputError(`skill new accepts exactly one input mode; received ${modes.join(", ")}. Use one of: description, --draft, --interactive.`); } /** Validate scaffold names against filesystem-safe kebab-case skill paths. */ function isValidSkillName(name) { return /^[a-z][a-z0-9-]{1,40}$/.test(name); } async function promptLine(rl, question, preset) { if (preset !== undefined) return preset; return (await rl.question(question)).trim(); } /** Deterministic prompt adapter for tests; answers are consumed in call order. */ function fakePrompts(answers) { let i = 0; /** Return the next scripted answer, defaulting to an empty response. */ const next = () => answers[i++] ?? ""; return { promptDescription: () => Promise.resolve(next()), promptName: (suggested) => { const answer = next(); return Promise.resolve(answer.length > 0 ? answer : suggested); }, confirmWrite: () => Promise.resolve(/^y/i.test(next())), close: () => { /* no-op */ }, }; } /** Real readline-backed prompt adapter for interactive CLI use. */ function readlinePrompts() { const readline = createInterface({ input: process.stdin, output: process.stdout, }); return { promptDescription: () => promptLine(readline, "Describe the skill you want to create:\n> ", undefined), promptName: async (suggested) => (await promptLine(readline, `Name (kebab-case, default ${suggested}): `, undefined)) || suggested, confirmWrite: async (path, scaffold) => { process.stdout.write(`\nProposed file: ${path}\n`); const preview = scaffold.split("\n").slice(0, 12).join("\n"); process.stdout.write(`---\n${preview}\n…\n---\n`); const answer = await readline.question("Write this file? (y/N) "); return /^y/i.test(answer.trim()); }, close: () => { readline.close(); }, }; } function suggestName(options, candidacy) { if (options.name && isValidSkillName(options.name)) return options.name; if (options.draftPath) { const stem = basename(options.draftPath).replace(/\.md$/, ""); if (isValidSkillName(stem)) return stem; } if (options.description) { const slug = options.description .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 32); if (isValidSkillName(slug)) return slug; } return `new-${candidacy.recommendedArtifact.type}`; } function describeArtifact(recommendation) { switch (recommendation.type) { case "skill": return `skill (${recommendation.subtype})`; case "reference": return `reference (${recommendation.subtype})`; case "instruction-file": return `instruction-file rule (${recommendation.reason})`; case "learning-loop": return `learning-loop (${recommendation.subtype})`; case "cli-command": return "cli-command"; case "do-not-create": return `do-not-create (${recommendation.reason})`; } } /** Render candidacy guidance when the request should not create a skill/playbook. */ function nonScaffoldOutput(candidacy) { return [ `Candidacy: ${describeArtifact(candidacy.recommendedArtifact)} (confidence ${Math.round(candidacy.confidence * 100)}%)`, "", "Reasoning:", ...candidacy.reasoning.map((r) => ` - ${r}`), "", "Next steps:", ...candidacy.nextSteps.map((s) => ` - ${s.action}`), "", "No skill or playbook will be scaffolded. Update the description or draft and re-run.", ]; } async function runDescriptionMode(description, options, prompts) { const projectRoot = options.projectRoot ?? process.cwd(); const candidacy = runCandidacyCheck({ kind: "description", text: description, }); const scaffolded = resolveScaffold(projectRoot, suggestName(options, candidacy), candidacy.recommendedArtifact); if (!scaffolded) { return { candidacy, proposedPath: null, scaffold: null, written: false, output: nonScaffoldOutput(candidacy), }; } const name = options.name ?? (await prompts.promptName(suggestName(options, candidacy))); if (!isValidSkillName(name)) { return { candidacy, proposedPath: null, scaffold: null, written: false, output: [ `Invalid name "${name}". Use kebab-case: lowercase letters, digits, and dashes.`, ], }; } const final = resolveScaffold(projectRoot, name, candidacy.recommendedArtifact); if (!final) { return { candidacy, proposedPath: null, scaffold: null, written: false, output: nonScaffoldOutput(candidacy), }; } const scaffold = fillTemplate(final.template, { NAME: name, DESCRIPTION: description, VERSION: getPackageVersion(), }); const written = await maybeWrite(final.proposedPath, scaffold, options, prompts); const output = [ `Candidacy: ${describeArtifact(candidacy.recommendedArtifact)} (confidence ${Math.round(candidacy.confidence * 100)}%)`, `Path: ${relative(projectRoot, final.proposedPath)}`, written ? "Wrote scaffold." : "Scaffold not written.", ]; let postScaffoldScore; if (written && !final.isReference) { postScaffoldScore = scoreFreshSkill(projectRoot, name); if (postScaffoldScore) { output.push(`Initial quality: ${postScaffoldScore.totalScore}/${postScaffoldScore.profileMax}`); } } return { candidacy, proposedPath: final.proposedPath, scaffold, written, postScaffoldScore, output, }; } function scoreFreshSkill(projectRoot, name) { const artifact = findArtifact(projectRoot, `skill:${name}`); if (!artifact) return undefined; const report = scoreArtifact(projectRoot, artifact); return { totalScore: report.totalScore, profileMax: report.profileMax, }; } async function maybeWrite(proposedPath, scaffold, options, prompts) { if (existsSync(proposedPath)) return false; const allow = options.shouldSkipConfirm ? true : await prompts.confirmWrite(proposedPath, scaffold); if (!allow) return false; mkdirSync(dirname(proposedPath), { recursive: true }); writeFileSync(proposedPath, scaffold); return true; } function runDraftMode(draftPath, options) { const projectRoot = options.projectRoot ?? process.cwd(); const absolutePath = resolve(draftPath); if (!existsSync(absolutePath)) { return { candidacy: { recommendedArtifact: { type: "do-not-create", reason: "no-clear-intent", }, confidence: 1, reasoning: [`draft file not found: ${absolutePath}`], nextSteps: [], }, proposedPath: null, scaffold: null, written: false, output: [`Draft file not found: ${absolutePath}`], }; } const content = readFileSync(absolutePath, "utf-8"); const suggestedName = basename(absolutePath, ".md"); const candidacy = runCandidacyCheck({ kind: "draft", content, suggestedName, }); const output = [ `Draft: ${relative(projectRoot, absolutePath)}`, `Candidacy: ${describeArtifact(candidacy.recommendedArtifact)} (confidence ${Math.round(candidacy.confidence * 100)}%)`, "", "Reasoning:", ...candidacy.reasoning.map((r) => ` - ${r}`), ]; const scaffolded = resolveScaffold(projectRoot, suggestedName, candidacy.recommendedArtifact); if (!scaffolded) { output.push("", "Next steps:", ...candidacy.nextSteps.map((s) => ` - ${s.action}`)); return { candidacy, proposedPath: null, scaffold: null, written: false, output, }; } const expectedPath = scaffolded.proposedPath; if (resolve(expectedPath) !== absolutePath) { output.push(""); output.push(`Expected location: ${relative(projectRoot, expectedPath)}`); output.push(`Suggested move: mv ${relative(projectRoot, absolutePath)} ${relative(projectRoot, expectedPath)}`); output.push("(not executed; review before moving.)"); } else if (!scaffolded.isReference) { const postScore = scoreFreshSkill(projectRoot, suggestedName); if (postScore) { output.push(`Quality: ${postScore.totalScore}/${postScore.profileMax} (snapshot of current draft)`); } } return { candidacy, proposedPath: expectedPath, scaffold: null, written: false, output, }; } async function runInteractiveMode(options, prompts) { const description = (await prompts.promptDescription()).trim(); if (description.length === 0) { return { candidacy: { recommendedArtifact: { type: "do-not-create", reason: "no-clear-intent", }, confidence: 1, reasoning: ["empty description"], nextSteps: [], }, proposedPath: null, scaffold: null, written: false, output: ["Empty description; aborting."], }; } return runDescriptionMode(description, options, prompts); } export async function runSkillNew(options) { assertSingleInputMode(options); const prompts = options.stdinAnswers !== undefined ? fakePrompts(options.stdinAnswers) : readlinePrompts(); try { if (options.draftPath) { return runDraftMode(options.draftPath, options); } if (options.shouldUseInteractivePrompt || (!options.description && !options.draftPath)) { return await runInteractiveMode(options, prompts); } if (options.description) { return await runDescriptionMode(options.description, options, prompts); } return { candidacy: { recommendedArtifact: { type: "do-not-create", reason: "no-clear-intent", }, confidence: 1, reasoning: ["no input provided"], nextSteps: [], }, proposedPath: null, scaffold: null, written: false, output: [ 'Usage: goat-flow skill new "<description>" | --draft <path> | --interactive', ], }; } finally { prompts.close(); } } //# sourceMappingURL=skill-author.js.map