diff --git a/.gitignore b/.gitignore index a4d8053..da05d18 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,9 @@ docs/deployment # Internal Paperclip scripts scripts/ls_agents.py scripts/paperclip-issue-update.sh +scripts/blog_discovery.py +scripts/blog_drafting.py +scripts/blog_publisher.py # Internal docs (not for end users) docs/typescript-migration.md diff --git a/defaults/agent-roles/README.md b/defaults/agent-roles/README.md new file mode 100644 index 0000000..8eae26b --- /dev/null +++ b/defaults/agent-roles/README.md @@ -0,0 +1,23 @@ +# Agent Role Templates + +These YAML files are built-in AgentRail role templates. They are intended to +replace loose setup-time "skills" prompts with concrete role-backed operating +profiles. + +Each template describes: + +- routing metadata used to assign work +- the behavior contract injected into managed runs +- preferred local tools, MCP servers, and skills +- workspace guardrails +- required checks and evidence +- escalation conditions + +The templates are deliberately runner-neutral. AgentRail should compile them to +runner-specific launch settings for Codex, Claude Code, Cursor, and other local +runners. + +The templates are also deliberately repo-neutral. Workspace entries use abstract +areas such as `backend_source`, `api_contracts`, `tests`, or `ci_configuration` +instead of concrete path globs. A project-level repo profile should map those +areas to actual paths during `agentrail init` or a later template-tailoring step. diff --git a/defaults/agent-roles/backend-api.yaml b/defaults/agent-roles/backend-api.yaml new file mode 100644 index 0000000..dfa5c06 --- /dev/null +++ b/defaults/agent-roles/backend-api.yaml @@ -0,0 +1,115 @@ +schemaVersion: agentrail.agent-role/v1 +id: backend-api +name: Backend/API +description: Handles server code, APIs, provider adapters, lifecycle behavior, data contracts, and backend tests. + +routing: + capabilityTags: + - backend + - api + - server + - provider-adapters + - lifecycle + - contracts + preferredLabels: + - backend + - api + - server + - provider + - lifecycle + - contract + taskTypes: + - api_feature + - backend_bug + - provider_integration + - lifecycle_fix + - contract_change + avoidLabels: + - ui + - visual + - copywriting + - docs-only + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - edit + - shell + deny: + - browser + - production_database + - deploy + mcp: + prefer: + - github + - linear + - circleci + deny: + - figma + - billing + - cloud_admin + skills: + prefer: + - backend-api + - api-contracts + - provider-integration + deny: + - visual-design + - copywriting + +instructions: | + You are responsible for backend and API work. + + Preserve existing runtime behavior unless the task explicitly changes it. + When API contracts change, update the implementation, tests, OpenAPI or SDK + surfaces, and documentation together when those surfaces exist. + + Prefer deterministic parsing, typed contracts, idempotent provider operations, + and explicit error handling. Avoid broad refactors unless they are required to + make the requested behavior correct. + + Do not make UI-only changes unless they are directly required by the backend + task. + +workspace: + preferredAreas: + - backend_source + - api_contracts + - provider_integrations + - sdk_source + - tests + cautionAreas: + - frontend_source + - documentation + - release_configuration + - deployment_configuration + forbiddenAreas: + - secrets + - environment_files + - production_credentials + +checks: + required: + - targeted_tests + - typecheck_or_build + suggested: + - openapi_lint_when_contract_changes + - sdk_smoke_when_sdk_changes + +evidence: + required: + - changed_files_summary + - validation_output + - contract_surface_notes + whenApplicable: + - migration_notes + - provider_state_notes + +escalation: + askUserWhen: + - A public API behavior is ambiguous. + - The task requires credentials or provider state not available locally. + - A migration or breaking SDK/API change is required. diff --git a/defaults/agent-roles/ci-release.yaml b/defaults/agent-roles/ci-release.yaml new file mode 100644 index 0000000..3213420 --- /dev/null +++ b/defaults/agent-roles/ci-release.yaml @@ -0,0 +1,110 @@ +schemaVersion: agentrail.agent-role/v1 +id: ci-release +name: CI/Release +description: Handles CI failures, workflow configuration, package publishing, deployment readiness, and release automation. + +routing: + capabilityTags: + - ci + - release + - github-actions + - circleci + - packaging + - deployment + preferredLabels: + - ci + - release + - github-actions + - circleci + - npm + - publishing + - deployment + taskTypes: + - ci_fix + - release_fix + - workflow_update + - packaging + - deploy_readiness + avoidLabels: + - ui-only + - docs-only + - design + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - edit + - shell + deny: + - secrets_write + - production_database + mcp: + prefer: + - github + - circleci + - vercel + deny: + - figma + - billing + skills: + prefer: + - ci-debugging + - release-management + - package-publishing + deny: + - visual-design + +instructions: | + You are responsible for CI, release, and deployment workflow work. + + Start from the failing job, release command, or deployment symptom. Reproduce + locally when practical, then make the smallest change that fixes the pipeline + or release path. Preserve security controls and do not weaken checks to make + CI pass. + + Do not publish packages, deploy to production, rotate credentials, or merge + release branches unless the task explicitly authorizes it. + +workspace: + preferredAreas: + - ci_configuration + - release_configuration + - package_manifests + - lockfiles + - deployment_configuration + - automation_scripts + - sdk_source + cautionAreas: + - application_source + - documentation + - database_migrations + forbiddenAreas: + - secrets + - environment_files + - production_credentials + +checks: + required: + - reproduce_or_explain_ci_failure + - targeted_validation + suggested: + - package_contents_check + - smoke_test_publish_artifact + +evidence: + required: + - failing_signal + - fix_summary + - validation_output + whenApplicable: + - package_artifact_notes + - deployment_notes + +escalation: + askUserWhen: + - Publishing or deployment credentials are required. + - A fix requires weakening required checks or branch protection. + - The task needs manual approval to release or deploy. diff --git a/defaults/agent-roles/code-review.yaml b/defaults/agent-roles/code-review.yaml new file mode 100644 index 0000000..e8bb359 --- /dev/null +++ b/defaults/agent-roles/code-review.yaml @@ -0,0 +1,102 @@ +schemaVersion: agentrail.agent-role/v1 +id: code-review +name: Code Review +description: Reviews code changes for correctness, regressions, security risks, maintainability, and missing tests. + +routing: + capabilityTags: + - code-review + - bug-finding + - risk-analysis + - test-gaps + - security-awareness + preferredLabels: + - review + - code-review + - pr-review + - risk + taskTypes: + - code_review + - pr_review + - review_feedback + - regression_audit + avoidLabels: + - implementation-only + - docs-only + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - shell + deny: + - deploy + - secrets_write + - production_database + mcp: + prefer: + - github + deny: + - figma + - cloud_admin + - billing + skills: + prefer: + - code-review + - security-review + - test-analysis + deny: + - release-management + +instructions: | + You are responsible for code review. + + Prioritize findings that can cause bugs, regressions, security issues, data + loss, broken public contracts, or missing validation. Do not spend review + space on style preferences unless they hide a concrete risk. + + Lead with findings ordered by severity. Each finding should include the file, + line or area, why it matters, and what should change. If there are no blocking + findings, say so clearly and mention residual risk or test gaps. + + Do not modify files unless the task explicitly asks for fixes. + +workspace: + preferredAreas: + - changed_files + - application_source + - tests + - api_contracts + - sdk_source + - ci_configuration + cautionAreas: + - generated_outputs + - build_artifacts + - vendored_dependencies + forbiddenAreas: + - secrets + - environment_files + - production_credentials + +checks: + required: + - diff_review + - risk_classification + suggested: + - targeted_test_read + - contract_surface_check + +evidence: + required: + - findings_or_clean_review + - residual_risk + whenApplicable: + - suggested_tests + - security_notes + +escalation: + askUserWhen: + - The review needs access to private CI logs or provider state. + - A suspected issue cannot be validated from the available diff or code. diff --git a/defaults/agent-roles/debugger.yaml b/defaults/agent-roles/debugger.yaml new file mode 100644 index 0000000..8fd78d4 --- /dev/null +++ b/defaults/agent-roles/debugger.yaml @@ -0,0 +1,108 @@ +schemaVersion: agentrail.agent-role/v1 +id: debugger +name: Debugger +description: Investigates failures, reproduces bugs, identifies root causes, and applies narrow fixes when requested. + +routing: + capabilityTags: + - debugging + - root-cause-analysis + - reproduction + - observability + - incident-fix + preferredLabels: + - bug + - failing-test + - regression + - incident + - flaky + - debug + taskTypes: + - bugfix + - failing_test + - incident_debug + - flaky_test + - root_cause + avoidLabels: + - feature + - copywriting + - design-only + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - edit + - shell + deny: + - deploy + - secrets_write + mcp: + prefer: + - github + - circleci + - sentry + deny: + - figma + - billing + skills: + prefer: + - debugging + - root-cause-analysis + - test-debugging + deny: + - visual-design + +instructions: | + You are responsible for debugging. + + Work from observed symptoms to a reproducible cause. Avoid guessing. First + identify the failing command, failing test, log signal, or user-visible + behavior. Then isolate the smallest responsible code path before changing + files. + + Prefer narrow fixes with regression coverage. Do not rewrite large areas of + code just because they are related to the bug. + +workspace: + preferredAreas: + - application_source + - tests + - automation_scripts + - ci_configuration + - api_contracts + - logs_and_diagnostics + cautionAreas: + - frontend_source + - sdk_source + - lockfiles + - generated_outputs + forbiddenAreas: + - secrets + - environment_files + - production_credentials + +checks: + required: + - reproduce_or_explain_failure + - targeted_regression_validation + suggested: + - add_regression_test + - rerun_original_failure + +evidence: + required: + - root_cause + - fix_summary + - validation_output + whenApplicable: + - regression_test_notes + - reproduction_artifacts + +escalation: + askUserWhen: + - The failure depends on credentials or private provider state. + - The bug cannot be reproduced and no deterministic signal is available. + - Multiple plausible root causes require a product decision. diff --git a/defaults/agent-roles/docs.yaml b/defaults/agent-roles/docs.yaml new file mode 100644 index 0000000..6ab5e4f --- /dev/null +++ b/defaults/agent-roles/docs.yaml @@ -0,0 +1,108 @@ +schemaVersion: agentrail.agent-role/v1 +id: docs +name: Docs +description: Handles documentation, guides, README updates, SDK examples, changelogs, and release notes. + +routing: + capabilityTags: + - docs + - technical-writing + - examples + - sdk-docs + - changelog + preferredLabels: + - docs + - documentation + - readme + - changelog + - examples + taskTypes: + - docs_update + - guide + - sdk_example + - changelog + - release_notes + avoidLabels: + - production + - database + - infra + - security-fix + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - edit + - shell + deny: + - deploy + - secrets_write + - production_database + mcp: + prefer: + - github + deny: + - cloud_admin + - billing + - production_database + skills: + prefer: + - technical-writing + - docs-update + - sdk-examples + deny: + - release-management + - security-audit + +instructions: | + You are responsible for documentation work. + + Verify docs against the live codebase before writing. Do not invent commands, + flags, API fields, SDK methods, or product behavior. When examples include + commands or code snippets, check the relevant source, package scripts, or SDK + types. + + Keep docs practical and concise. Link related docs when that helps users + complete the workflow. If documentation requires product behavior that does + not exist yet, say so instead of documenting it as shipped. + +workspace: + preferredAreas: + - documentation + - readme + - changelog + - examples + - sdk_documentation + - website_content + cautionAreas: + - backend_source + - frontend_source + - tests + - package_manifests + forbiddenAreas: + - secrets + - environment_files + - production_credentials + +checks: + required: + - docs_source_verification + suggested: + - docs_link_check + - sdk_smoke_when_examples_change + +evidence: + required: + - changed_files_summary + - verification_notes + whenApplicable: + - screenshot_or_render_notes + - link_check_output + +escalation: + askUserWhen: + - The requested docs describe behavior that is not implemented. + - A product positioning decision is needed. + - A public claim lacks evidence. diff --git a/defaults/agent-roles/frontend-ui.yaml b/defaults/agent-roles/frontend-ui.yaml new file mode 100644 index 0000000..aae08ae --- /dev/null +++ b/defaults/agent-roles/frontend-ui.yaml @@ -0,0 +1,120 @@ +schemaVersion: agentrail.agent-role/v1 +id: frontend-ui +name: Frontend/UI +description: Handles frontend application code, visual polish, responsive layout, accessibility, and browser-facing interactions. + +routing: + capabilityTags: + - frontend + - ui + - react + - css + - accessibility + - visual-review + preferredLabels: + - frontend + - ui + - design + - accessibility + - responsive + - css + taskTypes: + - frontend + - ui_feature + - visual_bug + - accessibility_fix + - design_system + avoidLabels: + - backend + - database + - infra + - release + - security + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - edit + - shell + - browser + deny: + - deploy + - secrets_write + - production_database + mcp: + prefer: + - browser + - figma + - github + deny: + - cloud_admin + - production_database + - billing + skills: + prefer: + - frontend-ui + - accessibility + - visual-regression + deny: + - security-audit + - release-management + - database-migration + +instructions: | + You are responsible for frontend and UI work. + + Start by understanding the existing design system, component conventions, + spacing, typography, color usage, and responsive behavior. Prefer existing + components and patterns over introducing new abstractions. + + For visible UI changes, verify the result in a browser or with screenshots + when the runner supports it. Check desktop and mobile layouts when the change + can affect responsive behavior. + + Do not change backend behavior, schemas, deployment configuration, or API + contracts unless the task explicitly requires it. If the task needs those + changes, report the dependency clearly. + +workspace: + preferredAreas: + - frontend_source + - frontend_components + - design_system + - styling + - static_assets + - browser_tests + cautionAreas: + - backend_source + - api_contracts + - database_migrations + - ci_configuration + - release_configuration + forbiddenAreas: + - secrets + - environment_files + - production_credentials + +checks: + required: + - typecheck_or_build + suggested: + - lint + - browser_smoke + - responsive_smoke + +evidence: + required: + - changed_files_summary + - validation_output + whenApplicable: + - screenshot_or_browser_notes + - accessibility_notes + +escalation: + askUserWhen: + - The task requires product or visual decisions not present in the issue. + - The task requires changing backend behavior or data contracts. + - Required design assets or Figma access are unavailable. diff --git a/defaults/agent-roles/security.yaml b/defaults/agent-roles/security.yaml new file mode 100644 index 0000000..421a608 --- /dev/null +++ b/defaults/agent-roles/security.yaml @@ -0,0 +1,117 @@ +schemaVersion: agentrail.agent-role/v1 +id: security +name: Security +description: Reviews and fixes security-sensitive code, auth boundaries, secret handling, webhooks, sandboxing, and permission policies. + +routing: + capabilityTags: + - security + - auth + - permissions + - secrets + - webhooks + - sandboxing + - threat-modeling + preferredLabels: + - security + - auth + - permissions + - secrets + - webhook + - sandbox + taskTypes: + - security_review + - auth_fix + - permission_fix + - secret_handling + - webhook_security + - sandbox_policy + avoidLabels: + - visual + - copywriting + - docs-only + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - edit + - shell + deny: + - deploy + - production_database + mcp: + prefer: + - github + - sentry + deny: + - figma + - billing + skills: + prefer: + - security-review + - threat-modeling + - secure-coding + deny: + - visual-design + - release-management + +instructions: | + You are responsible for security-sensitive work. + + Focus on concrete risk: auth bypasses, permission escalation, secret leakage, + unsafe filesystem access, webhook verification, injection, supply chain risk, + sandbox escapes, and unsafe defaults. + + Do not print secrets or tokens. Do not weaken security checks to make tests + pass. When a finding is speculative, mark it as such and explain what evidence + would confirm it. + + Security fixes should be narrow, testable, and preserve existing legitimate + workflows. + +workspace: + preferredAreas: + - security_sensitive_code + - authentication + - authorization + - permission_policy + - webhook_handlers + - sandbox_policy + - dependency_manifests + - tests + - security_documentation + cautionAreas: + - frontend_source + - general_documentation + - lockfiles + - generated_outputs + forbiddenAreas: + - secrets + - environment_files + - production_credentials + +checks: + required: + - targeted_security_tests + - regression_validation + suggested: + - threat_model_notes + - dependency_review_when_relevant + +evidence: + required: + - risk_summary + - mitigation_summary + - validation_output + whenApplicable: + - dependency_advisory_notes + - secret_rotation_notes + +escalation: + askUserWhen: + - A fix requires rotating credentials or changing provider secrets. + - The requested behavior conflicts with a security boundary. + - Production incident response or disclosure coordination is required. diff --git a/defaults/agent-roles/test-automation.yaml b/defaults/agent-roles/test-automation.yaml new file mode 100644 index 0000000..7b35e35 --- /dev/null +++ b/defaults/agent-roles/test-automation.yaml @@ -0,0 +1,106 @@ +schemaVersion: agentrail.agent-role/v1 +id: test-automation +name: Test Automation +description: Adds, repairs, and strengthens automated tests, fixtures, smoke tests, and validation scripts. + +routing: + capabilityTags: + - testing + - test-automation + - fixtures + - smoke-tests + - regression-tests + preferredLabels: + - tests + - testing + - test-coverage + - flaky-test + - validation + taskTypes: + - test_addition + - regression_test + - fixture_update + - smoke_test + - validation_script + avoidLabels: + - ui-design + - release-only + - docs-only + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + - edit + - shell + - browser + deny: + - deploy + - secrets_write + - production_database + mcp: + prefer: + - github + - browser + deny: + - figma + - billing + - cloud_admin + skills: + prefer: + - test-automation + - regression-testing + - e2e-testing + deny: + - release-management + +instructions: | + You are responsible for automated testing and validation. + + Add tests that prove behavior, not implementation details. Prefer small + focused fixtures and deterministic assertions. When fixing flaky tests, + identify the source of nondeterminism instead of increasing sleeps blindly. + + Keep production code changes minimal and only make them when needed to expose + testable behavior or fix a defect discovered by the tests. + +workspace: + preferredAreas: + - tests + - test_fixtures + - automation_scripts + - application_source + - frontend_source + - backend_source + cautionAreas: + - documentation + - ci_configuration + - lockfiles + - release_configuration + forbiddenAreas: + - secrets + - environment_files + - production_credentials + +checks: + required: + - targeted_tests + suggested: + - full_relevant_suite + - flake_resistance_check + +evidence: + required: + - test_scope + - validation_output + whenApplicable: + - flake_analysis + - coverage_notes + +escalation: + askUserWhen: + - The expected behavior is unclear. + - Stable test data or external service access is missing. + - Existing tests conflict with the requested behavior. diff --git a/package-lock.json b/package-lock.json index 2537277..9d25a6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "SEE LICENSE IN LICENSE", "dependencies": { "@clack/prompts": "^1.3.0", - "oh-my-logo": "^0.4.0" + "oh-my-logo": "^0.4.0", + "yaml": "^2.9.0" }, "bin": { "agentrail": "bin/agentrail.js" @@ -4480,6 +4481,21 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", diff --git a/package.json b/package.json index 4a7582a..2bca1fc 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ }, "files": [ "bin", + "defaults", "dist", "README.md", "LICENSE" @@ -39,6 +40,7 @@ }, "dependencies": { "@clack/prompts": "^1.3.0", - "oh-my-logo": "^0.4.0" + "oh-my-logo": "^0.4.0", + "yaml": "^2.9.0" } } diff --git a/scripts/verify-npm-package-contents.mjs b/scripts/verify-npm-package-contents.mjs index 2e3d83c..b416932 100644 --- a/scripts/verify-npm-package-contents.mjs +++ b/scripts/verify-npm-package-contents.mjs @@ -6,6 +6,17 @@ import { pathToFileURL } from "node:url"; const FORBIDDEN_SEGMENTS = new Set([".idea", ".vscode", "browser-artifacts"]); const FORBIDDEN_EXTENSIONS = [".pem", ".key", ".p12", ".pfx", ".tgz"]; +const REQUIRED_PACKAGE_FILES = [ + "defaults/agent-roles/README.md", + "defaults/agent-roles/backend-api.yaml", + "defaults/agent-roles/ci-release.yaml", + "defaults/agent-roles/code-review.yaml", + "defaults/agent-roles/debugger.yaml", + "defaults/agent-roles/docs.yaml", + "defaults/agent-roles/frontend-ui.yaml", + "defaults/agent-roles/security.yaml", + "defaults/agent-roles/test-automation.yaml", +]; export function findForbiddenPackageFiles(files) { return files @@ -25,6 +36,11 @@ export function findForbiddenPackageFiles(files) { .sort(); } +export function findMissingRequiredPackageFiles(files) { + const packagedFiles = new Set(files.map((filePath) => String(filePath).replace(/\\/gu, "/"))); + return REQUIRED_PACKAGE_FILES.filter((filePath) => !packagedFiles.has(filePath)); +} + function packageFilesFromDryRun() { const output = execFileSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"], { cwd: process.cwd(), @@ -62,6 +78,15 @@ function main() { process.exitCode = 1; return; } + const missing = findMissingRequiredPackageFiles(files); + if (missing.length > 0) { + console.error("Refusing to pack @agentrail-core/cli because required files are missing:"); + for (const filePath of missing) { + console.error(`- ${filePath}`); + } + process.exitCode = 1; + return; + } console.log(`npm package content guard passed (${files.length} files checked).`); } diff --git a/src/agent-role-template.ts b/src/agent-role-template.ts new file mode 100644 index 0000000..0179bc6 --- /dev/null +++ b/src/agent-role-template.ts @@ -0,0 +1,317 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { parse as parseYaml } from "yaml"; + +export const AGENT_ROLE_TEMPLATE_SCHEMA_VERSION = "agentrail.agent-role/v1"; + +export interface CapabilityPreference { + allow?: string[]; + prefer?: string[]; + deny?: string[]; +} + +export interface AgentRoleTemplate { + schemaVersion: typeof AGENT_ROLE_TEMPLATE_SCHEMA_VERSION; + id: string; + name: string; + description: string; + routing: { + capabilityTags: string[]; + preferredLabels: string[]; + taskTypes: string[]; + avoidLabels: string[]; + }; + runtime: { + cleanSlate: boolean; + tools: { + builtin: CapabilityPreference; + mcp: CapabilityPreference; + skills: CapabilityPreference; + }; + }; + instructions: string; + workspace: { + preferredAreas: string[]; + cautionAreas: string[]; + forbiddenAreas: string[]; + }; + checks: { + required: string[]; + suggested: string[]; + }; + evidence: { + required: string[]; + whenApplicable: string[]; + }; + escalation: { + askUserWhen: string[]; + }; +} + +export interface LoadedAgentRoleTemplate { + template: AgentRoleTemplate; + sourcePath: string; +} + +const BUILT_IN_TEMPLATE_DIR = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../defaults/agent-roles", +); + +const ID_PATTERN = /^[a-z][a-z0-9-]*$/u; +const AREA_PATTERN = /^[a-z][a-z0-9_]*$/u; +const TEMPLATE_EXTENSION = ".yaml"; + +const TOP_LEVEL_KEYS = new Set([ + "schemaVersion", + "id", + "name", + "description", + "routing", + "runtime", + "instructions", + "workspace", + "checks", + "evidence", + "escalation", +]); + +const ROUTING_KEYS = new Set(["capabilityTags", "preferredLabels", "taskTypes", "avoidLabels"]); +const RUNTIME_KEYS = new Set(["cleanSlate", "tools"]); +const TOOLS_KEYS = new Set(["builtin", "mcp", "skills"]); +const CAPABILITY_PREFERENCE_KEYS = new Set(["allow", "prefer", "deny"]); +const WORKSPACE_KEYS = new Set(["preferredAreas", "cautionAreas", "forbiddenAreas"]); +const CHECKS_KEYS = new Set(["required", "suggested"]); +const EVIDENCE_KEYS = new Set(["required", "whenApplicable"]); +const ESCALATION_KEYS = new Set(["askUserWhen"]); + +export function builtInAgentRoleTemplatesDir(): string { + return BUILT_IN_TEMPLATE_DIR; +} + +export async function loadBuiltInAgentRoleTemplates(): Promise { + return loadAgentRoleTemplatesFromDir(BUILT_IN_TEMPLATE_DIR); +} + +export async function getBuiltInAgentRoleTemplate(id: string): Promise { + const templates = await loadBuiltInAgentRoleTemplates(); + return templates.find((entry) => entry.template.id === id) ?? null; +} + +export async function loadAgentRoleTemplatesFromDir(templateDir: string): Promise { + const entries = await readdir(templateDir, { withFileTypes: true }); + const templateFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(TEMPLATE_EXTENSION)) + .map((entry) => path.join(templateDir, entry.name)) + .sort((a, b) => a.localeCompare(b)); + + const loaded = await Promise.all(templateFiles.map(async (sourcePath) => { + const yaml = await readFile(sourcePath, "utf8"); + return { + template: parseAgentRoleTemplateYaml(yaml, sourcePath), + sourcePath, + }; + })); + + assertUniqueTemplateIds(loaded); + return loaded; +} + +export function parseAgentRoleTemplateYaml(yaml: string, sourcePath = ""): AgentRoleTemplate { + let parsed: unknown; + try { + parsed = parseYaml(yaml); + } catch (error) { + throw new Error(`Agent role template ${sourcePath} is not valid YAML: ${errorMessage(error)}`); + } + return validateAgentRoleTemplate(parsed, sourcePath); +} + +export function validateAgentRoleTemplate(value: unknown, sourcePath = ""): AgentRoleTemplate { + const root = requireRecord(value, sourcePath, "template"); + rejectUnknownKeys(root, TOP_LEVEL_KEYS, sourcePath, "template"); + + const schemaVersion = requireString(root.schemaVersion, sourcePath, "schemaVersion"); + if (schemaVersion !== AGENT_ROLE_TEMPLATE_SCHEMA_VERSION) { + throw new Error(`Agent role template ${sourcePath} has unsupported schemaVersion ${schemaVersion}.`); + } + + const id = requireString(root.id, sourcePath, "id"); + if (!ID_PATTERN.test(id)) { + throw new Error(`Agent role template ${sourcePath} has invalid id ${id}. Use lowercase kebab-case.`); + } + + return { + schemaVersion: AGENT_ROLE_TEMPLATE_SCHEMA_VERSION, + id, + name: requireString(root.name, sourcePath, "name"), + description: requireString(root.description, sourcePath, "description"), + routing: validateRouting(root.routing, sourcePath), + runtime: validateRuntime(root.runtime, sourcePath), + instructions: requireString(root.instructions, sourcePath, "instructions"), + workspace: validateWorkspace(root.workspace, sourcePath), + checks: validateChecks(root.checks, sourcePath), + evidence: validateEvidence(root.evidence, sourcePath), + escalation: validateEscalation(root.escalation, sourcePath), + }; +} + +export function assertUniqueTemplateIds(templates: LoadedAgentRoleTemplate[]): void { + const seen = new Map(); + for (const { template, sourcePath } of templates) { + const previous = seen.get(template.id); + if (previous) { + throw new Error(`Duplicate agent role template id ${template.id} in ${previous} and ${sourcePath}.`); + } + seen.set(template.id, sourcePath); + } +} + +function validateRouting(value: unknown, sourcePath: string): AgentRoleTemplate["routing"] { + const routing = requireRecord(value, sourcePath, "routing"); + rejectUnknownKeys(routing, ROUTING_KEYS, sourcePath, "routing"); + return { + capabilityTags: requireStringArray(routing.capabilityTags, sourcePath, "routing.capabilityTags"), + preferredLabels: requireStringArray(routing.preferredLabels, sourcePath, "routing.preferredLabels"), + taskTypes: requireStringArray(routing.taskTypes, sourcePath, "routing.taskTypes"), + avoidLabels: requireStringArray(routing.avoidLabels, sourcePath, "routing.avoidLabels"), + }; +} + +function validateRuntime(value: unknown, sourcePath: string): AgentRoleTemplate["runtime"] { + const runtime = requireRecord(value, sourcePath, "runtime"); + rejectUnknownKeys(runtime, RUNTIME_KEYS, sourcePath, "runtime"); + const tools = requireRecord(runtime.tools, sourcePath, "runtime.tools"); + rejectUnknownKeys(tools, TOOLS_KEYS, sourcePath, "runtime.tools"); + + return { + cleanSlate: requireBoolean(runtime.cleanSlate, sourcePath, "runtime.cleanSlate"), + tools: { + builtin: validateCapabilityPreference(tools.builtin, sourcePath, "runtime.tools.builtin"), + mcp: validateCapabilityPreference(tools.mcp, sourcePath, "runtime.tools.mcp"), + skills: validateCapabilityPreference(tools.skills, sourcePath, "runtime.tools.skills"), + }, + }; +} + +function validateCapabilityPreference(value: unknown, sourcePath: string, fieldPath: string): CapabilityPreference { + const preference = requireRecord(value, sourcePath, fieldPath); + rejectUnknownKeys(preference, CAPABILITY_PREFERENCE_KEYS, sourcePath, fieldPath); + return { + ...optionalStringArrayProperty(preference, "allow", sourcePath, fieldPath), + ...optionalStringArrayProperty(preference, "prefer", sourcePath, fieldPath), + ...optionalStringArrayProperty(preference, "deny", sourcePath, fieldPath), + }; +} + +function validateWorkspace(value: unknown, sourcePath: string): AgentRoleTemplate["workspace"] { + const workspace = requireRecord(value, sourcePath, "workspace"); + rejectUnknownKeys(workspace, WORKSPACE_KEYS, sourcePath, "workspace"); + return { + preferredAreas: requireAreaArray(workspace.preferredAreas, sourcePath, "workspace.preferredAreas"), + cautionAreas: requireAreaArray(workspace.cautionAreas, sourcePath, "workspace.cautionAreas"), + forbiddenAreas: requireAreaArray(workspace.forbiddenAreas, sourcePath, "workspace.forbiddenAreas"), + }; +} + +function validateChecks(value: unknown, sourcePath: string): AgentRoleTemplate["checks"] { + const checks = requireRecord(value, sourcePath, "checks"); + rejectUnknownKeys(checks, CHECKS_KEYS, sourcePath, "checks"); + return { + required: requireStringArray(checks.required, sourcePath, "checks.required"), + suggested: requireStringArray(checks.suggested, sourcePath, "checks.suggested"), + }; +} + +function validateEvidence(value: unknown, sourcePath: string): AgentRoleTemplate["evidence"] { + const evidence = requireRecord(value, sourcePath, "evidence"); + rejectUnknownKeys(evidence, EVIDENCE_KEYS, sourcePath, "evidence"); + return { + required: requireStringArray(evidence.required, sourcePath, "evidence.required"), + whenApplicable: requireStringArray(evidence.whenApplicable, sourcePath, "evidence.whenApplicable"), + }; +} + +function validateEscalation(value: unknown, sourcePath: string): AgentRoleTemplate["escalation"] { + const escalation = requireRecord(value, sourcePath, "escalation"); + rejectUnknownKeys(escalation, ESCALATION_KEYS, sourcePath, "escalation"); + return { + askUserWhen: requireStringArray(escalation.askUserWhen, sourcePath, "escalation.askUserWhen"), + }; +} + +function optionalStringArrayProperty( + record: Record, + key: "allow" | "prefer" | "deny", + sourcePath: string, + parentPath: string, +): Partial> { + if (record[key] === undefined) { + return {}; + } + return { [key]: requireStringArray(record[key], sourcePath, `${parentPath}.${key}`) }; +} + +function requireAreaArray(value: unknown, sourcePath: string, fieldPath: string): string[] { + const areas = requireStringArray(value, sourcePath, fieldPath); + for (const area of areas) { + if (!AREA_PATTERN.test(area)) { + throw new Error(`Agent role template ${sourcePath} field ${fieldPath} contains invalid area ${area}. Use lowercase snake_case area names, not path globs.`); + } + if (area.includes("/") || area.includes("*")) { + throw new Error(`Agent role template ${sourcePath} field ${fieldPath} contains path-like area ${area}. Use abstract repo areas instead.`); + } + } + return areas; +} + +function requireStringArray(value: unknown, sourcePath: string, fieldPath: string): string[] { + if (!Array.isArray(value)) { + throw new Error(`Agent role template ${sourcePath} field ${fieldPath} must be an array of strings.`); + } + for (const item of value) { + if (typeof item !== "string" || item.trim().length === 0) { + throw new Error(`Agent role template ${sourcePath} field ${fieldPath} must be an array of non-empty strings.`); + } + } + return [...value]; +} + +function requireString(value: unknown, sourcePath: string, fieldPath: string): string { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`Agent role template ${sourcePath} field ${fieldPath} must be a non-empty string.`); + } + return value; +} + +function requireBoolean(value: unknown, sourcePath: string, fieldPath: string): boolean { + if (typeof value !== "boolean") { + throw new Error(`Agent role template ${sourcePath} field ${fieldPath} must be a boolean.`); + } + return value; +} + +function requireRecord(value: unknown, sourcePath: string, fieldPath: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`Agent role template ${sourcePath} field ${fieldPath} must be an object.`); + } + return value as Record; +} + +function rejectUnknownKeys( + record: Record, + allowedKeys: Set, + sourcePath: string, + fieldPath: string, +): void { + const unknownKeys = Object.keys(record).filter((key) => !allowedKeys.has(key)); + if (unknownKeys.length > 0) { + throw new Error(`Agent role template ${sourcePath} field ${fieldPath} has unknown keys: ${unknownKeys.join(", ")}.`); + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/test/agent-role-template.test.ts b/test/agent-role-template.test.ts new file mode 100644 index 0000000..32523b9 --- /dev/null +++ b/test/agent-role-template.test.ts @@ -0,0 +1,161 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + AGENT_ROLE_TEMPLATE_SCHEMA_VERSION, + getBuiltInAgentRoleTemplate, + loadAgentRoleTemplatesFromDir, + loadBuiltInAgentRoleTemplates, + parseAgentRoleTemplateYaml, +} from "../src/agent-role-template.ts"; + +const EXPECTED_BUILT_IN_TEMPLATE_IDS = [ + "backend-api", + "ci-release", + "code-review", + "debugger", + "docs", + "frontend-ui", + "security", + "test-automation", +]; + +function templateYaml(id = "sample-role"): string { + return `schemaVersion: ${AGENT_ROLE_TEMPLATE_SCHEMA_VERSION} +id: ${id} +name: Sample Role +description: Handles sample tasks. + +routing: + capabilityTags: + - sample + preferredLabels: + - sample + taskTypes: + - sample_task + avoidLabels: + - unrelated + +runtime: + cleanSlate: true + tools: + builtin: + allow: + - read + deny: + - deploy + mcp: + prefer: + - github + skills: + prefer: + - sample + +instructions: | + Do the sample work. + +workspace: + preferredAreas: + - backend_source + cautionAreas: + - ci_configuration + forbiddenAreas: + - secrets + +checks: + required: + - targeted_tests + suggested: + - typecheck_or_build + +evidence: + required: + - changed_files_summary + whenApplicable: + - screenshot_or_browser_notes + +escalation: + askUserWhen: + - Required credentials are unavailable. +`; +} + +function makeTemplateDir(t: test.TestContext): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-role-template-")); + t.after(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +test("loads all built-in agent role templates", async () => { + const loaded = await loadBuiltInAgentRoleTemplates(); + assert.deepEqual(loaded.map((entry) => entry.template.id), EXPECTED_BUILT_IN_TEMPLATE_IDS); + assert.ok(loaded.every((entry) => entry.sourcePath.endsWith(".yaml"))); + assert.ok(loaded.every((entry) => entry.template.runtime.cleanSlate)); +}); + +test("looks up a built-in template by id", async () => { + const frontend = await getBuiltInAgentRoleTemplate("frontend-ui"); + assert.ok(frontend); + assert.equal(frontend.template.name, "Frontend/UI"); + assert.ok(frontend.template.routing.capabilityTags.includes("frontend")); + + const missing = await getBuiltInAgentRoleTemplate("unknown-role"); + assert.equal(missing, null); +}); + +test("built-in workspace areas are repo-agnostic", async () => { + const loaded = await loadBuiltInAgentRoleTemplates(); + for (const { template } of loaded) { + const areas = [ + ...template.workspace.preferredAreas, + ...template.workspace.cautionAreas, + ...template.workspace.forbiddenAreas, + ]; + assert.ok(areas.length > 0, `${template.id} should declare workspace areas`); + assert.ok(areas.every((area) => !area.includes("/") && !area.includes("*")), `${template.id} should not use path globs`); + } +}); + +test("parses a valid inline template", () => { + const template = parseAgentRoleTemplateYaml(templateYaml("valid-sample")); + assert.equal(template.id, "valid-sample"); + assert.deepEqual(template.workspace.preferredAreas, ["backend_source"]); + assert.deepEqual(template.runtime.tools.builtin.allow, ["read"]); +}); + +test("rejects unsupported schema versions", () => { + assert.throws( + () => parseAgentRoleTemplateYaml(templateYaml().replace(AGENT_ROLE_TEMPLATE_SCHEMA_VERSION, "agentrail.agent-role/v99")), + /unsupported schemaVersion/u, + ); +}); + +test("rejects path-like workspace entries", () => { + assert.throws( + () => parseAgentRoleTemplateYaml(templateYaml().replace("backend_source", "src/**")), + /invalid area|path-like area/u, + ); +}); + +test("rejects concrete workspace path fields", () => { + assert.throws( + () => parseAgentRoleTemplateYaml(templateYaml().replace("preferredAreas:", "preferredPaths:")), + /unknown keys: preferredPaths/u, + ); +}); + +test("rejects duplicate template ids in a directory", async (t) => { + const dir = makeTemplateDir(t); + fs.writeFileSync(path.join(dir, "a.yaml"), templateYaml("duplicate-role"), "utf8"); + fs.writeFileSync(path.join(dir, "b.yaml"), templateYaml("duplicate-role"), "utf8"); + + await assert.rejects( + () => loadAgentRoleTemplatesFromDir(dir), + /Duplicate agent role template id duplicate-role/u, + ); +}); diff --git a/test/repo-cleanup.test.js b/test/repo-cleanup.test.js index c128dc5..ff5860c 100644 --- a/test/repo-cleanup.test.js +++ b/test/repo-cleanup.test.js @@ -81,7 +81,7 @@ test("npm packaging blocks secrets and generated tarballs", async () => { assert.match(npmignore, /^\*\.pfx$/m); assert.match(npmignore, /^\*\.tgz$/m); assert.match(packageJson.scripts.prepack, /verify-npm-package-contents\.mjs/); - assert.deepEqual(packageJson.files, ["bin", "dist", "README.md", "LICENSE"]); + assert.deepEqual(packageJson.files, ["bin", "defaults", "dist", "README.md", "LICENSE"]); }); test("root package does not depend on its own published CLI package", async () => {