diff --git a/src/cli.ts b/src/cli.ts index 5f7a310..628a562 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -58,6 +58,8 @@ import { registerApprovalCommand } from './commands/approval.js'; import { registerDeployCommand } from './commands/deploy.js'; import { registerEvalCommand } from './commands/eval.js'; import { registerCognitionCommand } from './commands/cognition.js'; +import { registerCatalogCommands } from './commands/catalog.js'; +import { registerReleaseCommands } from './commands/release-check.js'; // All other command handlers are lazy-loaded via dynamic import() inside // action handlers. Only the invoked command's dependencies are loaded, @@ -1043,6 +1045,10 @@ registerDeployCommand(program); // Cognition command group - business cognition engine registerCognitionCommand(program); +// IDP — service catalog, scorecards, release checks +registerCatalogCommands(program); +registerReleaseCommands(program); + // Providers command - show LLM CLI availability for multi-LLM support program .command('providers') diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts new file mode 100644 index 0000000..bc28d49 --- /dev/null +++ b/src/commands/catalog.ts @@ -0,0 +1,222 @@ +/** + * squads catalog — service catalog commands. + * + * squads catalog list Show all services + * squads catalog show Service details + * squads catalog check Validate against scorecard + */ + +import { Command } from 'commander'; +import { loadCatalog, loadService, loadScorecard } from '../lib/idp/catalog-loader.js'; +import { evaluateService } from '../lib/idp/scorecard-engine.js'; +import { findIdpDir } from '../lib/idp/resolver.js'; +import type { CatalogEntry } from '../lib/idp/types.js'; +import { colors, bold, RESET, writeLine } from '../lib/terminal.js'; + +function noIdp(): boolean { + if (!findIdpDir()) { + writeLine(` ${colors.red}IDP not found${RESET}`); + writeLine(` ${colors.dim}Set SQUADS_IDP_PATH or clone the idp repo as a sibling directory.${RESET}`); + return true; + } + return false; +} + +export function registerCatalogCommands(program: Command): void { + const catalog = program + .command('catalog') + .description('Service catalog — browse, inspect, and validate services'); + + // ── catalog list ── + catalog + .command('list') + .description('List all services in the catalog') + .option('--type ', 'Filter by type (product, domain)') + .option('--json', 'Output as JSON') + .action((opts) => { + if (noIdp()) return; + + const entries = loadCatalog(); + if (entries.length === 0) { + writeLine(' No catalog entries found.'); + return; + } + + const filtered = opts.type + ? entries.filter(e => e.spec.type === opts.type) + : entries; + + if (opts.json) { + console.log(JSON.stringify(filtered.map(e => ({ + name: e.metadata.name, + type: e.spec.type, + stack: e.spec.stack, + owner: e.metadata.owner, + repo: e.metadata.repo, + })), null, 2)); + return; + } + + writeLine(); + writeLine(` ${bold}Service Catalog${RESET} (${filtered.length} services)`); + writeLine(); + + // Group by type + const products = filtered.filter(e => e.spec.type === 'product'); + const domains = filtered.filter(e => e.spec.type === 'domain'); + + if (products.length > 0) { + writeLine(` ${colors.cyan}Product Services${RESET}`); + writeLine(); + for (const e of products) { + const ci = e.spec.ci.template ? `ci:${e.spec.ci.template}` : 'no-ci'; + const deploy = e.spec.deploy?.target || 'manual'; + writeLine(` ${bold}${e.metadata.name}${RESET} ${colors.dim}${e.spec.stack} | ${ci} | deploy:${deploy} | owner:${e.metadata.owner}${RESET}`); + writeLine(` ${colors.dim}${e.metadata.description}${RESET}`); + } + writeLine(); + } + + if (domains.length > 0) { + writeLine(` ${colors.cyan}Domain Repos${RESET}`); + writeLine(); + for (const e of domains) { + writeLine(` ${e.metadata.name} ${colors.dim}owner:${e.metadata.owner} | ${e.metadata.repo}${RESET}`); + } + writeLine(); + } + }); + + // ── catalog show ── + catalog + .command('show ') + .description('Show detailed info for a service') + .option('--json', 'Output as JSON') + .action((serviceName: string, opts) => { + if (noIdp()) return; + + const entry = loadService(serviceName); + if (!entry) { + writeLine(` ${colors.red}Service not found: ${serviceName}${RESET}`); + writeLine(` ${colors.dim}Run 'squads catalog list' to see available services.${RESET}`); + return; + } + + if (opts.json) { + console.log(JSON.stringify(entry, null, 2)); + return; + } + + writeLine(); + writeLine(` ${bold}${entry.metadata.name}${RESET} ${colors.dim}${entry.spec.type}${RESET}`); + writeLine(` ${entry.metadata.description}`); + writeLine(); + + writeLine(` ${colors.cyan}General${RESET}`); + writeLine(` Owner: ${entry.metadata.owner}`); + writeLine(` Repo: ${entry.metadata.repo}`); + writeLine(` Stack: ${entry.spec.stack}${entry.spec.framework ? ` (${entry.spec.framework})` : ''}`); + writeLine(` Scorecard: ${entry.spec.scorecard}`); + writeLine(` Tags: ${entry.metadata.tags?.join(', ') || 'none'}`); + writeLine(); + + writeLine(` ${colors.cyan}Branches${RESET}`); + writeLine(` Default: ${entry.spec.branches.default}`); + writeLine(` Workflow: ${entry.spec.branches.workflow}`); + if (entry.spec.branches.development) { + writeLine(` Dev branch: ${entry.spec.branches.development}`); + } + writeLine(); + + if (entry.spec.ci.template) { + writeLine(` ${colors.cyan}CI/CD${RESET}`); + writeLine(` Template: ${entry.spec.ci.template}`); + writeLine(` Checks: ${entry.spec.ci.required_checks.join(', ') || 'none'}`); + if (entry.spec.ci.build_command) writeLine(` Build: ${entry.spec.ci.build_command}`); + if (entry.spec.ci.test_command) writeLine(` Test: ${entry.spec.ci.test_command}`); + writeLine(); + } + + if (entry.spec.deploy) { + writeLine(` ${colors.cyan}Deploy${RESET}`); + writeLine(` Target: ${entry.spec.deploy.target}`); + writeLine(` Trigger: ${entry.spec.deploy.trigger}`); + if (entry.spec.deploy.environments) { + for (const env of entry.spec.deploy.environments) { + writeLine(` ${env.name}: ${env.url}`); + } + } + writeLine(); + } + + if (entry.spec.dependencies.runtime.length > 0) { + writeLine(` ${colors.cyan}Dependencies${RESET}`); + for (const dep of entry.spec.dependencies.runtime) { + const req = dep.required === false ? '(optional)' : '(required)'; + writeLine(` → ${dep.service} ${dep.version || ''} ${req}`); + writeLine(` ${colors.dim}${dep.description}${RESET}`); + } + writeLine(); + } + + if (entry.spec.health.length > 0) { + writeLine(` ${colors.cyan}Health Endpoints${RESET}`); + for (const h of entry.spec.health) { + writeLine(` ${h.name}: ${h.url}`); + } + writeLine(); + } + }); + + // ── catalog check ── + catalog + .command('check [service]') + .description('Run scorecard checks for a service (or all)') + .option('--json', 'Output as JSON') + .action((serviceName: string | undefined, opts) => { + if (noIdp()) return; + + const entries = serviceName + ? [loadService(serviceName)].filter(Boolean) as CatalogEntry[] + : loadCatalog(); + + if (entries.length === 0) { + writeLine(` ${colors.red}No services found${RESET}`); + return; + } + + const results = []; + + for (const entry of entries) { + const scorecard = loadScorecard(entry.spec.scorecard); + if (!scorecard) { + writeLine(` ${colors.dim}No scorecard '${entry.spec.scorecard}' for ${entry.metadata.name}${RESET}`); + continue; + } + + const result = evaluateService(entry, scorecard); + results.push(result); + + if (!opts.json) { + const gradeColor = result.grade === 'A' ? colors.green + : result.grade === 'B' ? colors.cyan + : result.grade === 'C' ? colors.yellow + : colors.red; + + writeLine(); + writeLine(` ${bold}${result.service}${RESET} ${gradeColor}${result.grade}${RESET} (${result.score}/100)`); + + for (const check of result.checks) { + const icon = check.passed ? `${colors.green}pass${RESET}` : `${colors.red}fail${RESET}`; + writeLine(` ${icon} ${check.name} ${colors.dim}(${check.detail})${RESET}`); + } + } + } + + if (opts.json) { + console.log(JSON.stringify(results, null, 2)); + } else { + writeLine(); + } + }); +} diff --git a/src/commands/release-check.ts b/src/commands/release-check.ts new file mode 100644 index 0000000..5ecc801 --- /dev/null +++ b/src/commands/release-check.ts @@ -0,0 +1,140 @@ +/** + * squads release — release pre-check and status. + * + * squads release pre-check Validate dependencies before deploy + */ + +import { Command } from 'commander'; +import { loadService, loadDependencyGraph } from '../lib/idp/catalog-loader.js'; +import { findIdpDir } from '../lib/idp/resolver.js'; +import { colors, bold, RESET, writeLine } from '../lib/terminal.js'; + +async function checkHealth(url: string, expect: number): Promise<{ ok: boolean; status: number | string }> { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(10000) }); + return { ok: response.status === expect, status: response.status }; + } catch (e) { + return { ok: false, status: e instanceof Error ? e.message : 'unreachable' }; + } +} + +export function registerReleaseCommands(program: Command): void { + const release = program + .command('release') + .description('Release management — pre-deploy checks and status'); + + release + .command('pre-check ') + .description('Validate dependencies and health before deploying a service') + .option('--skip-health', 'Skip health endpoint checks') + .action(async (serviceName: string, opts) => { + const idpDir = findIdpDir(); + if (!idpDir) { + writeLine(` ${colors.red}IDP not found${RESET}`); + return; + } + + const service = loadService(serviceName); + if (!service) { + writeLine(` ${colors.red}Service not found: ${serviceName}${RESET}`); + return; + } + + const graph = loadDependencyGraph(); + const deps = service.spec.dependencies.runtime; + + writeLine(); + writeLine(` ${bold}Release Pre-Check: ${serviceName}${RESET}`); + writeLine(); + + let allGreen = true; + + // Check dependencies + if (deps.length === 0) { + writeLine(` ${colors.green}pass${RESET} No runtime dependencies`); + } else { + writeLine(` ${colors.cyan}Dependencies${RESET}`); + for (const dep of deps) { + const depService = loadService(dep.service); + const req = dep.required !== false; + + if (!depService) { + if (dep.type === 'infrastructure') { + writeLine(` ${colors.dim}skip${RESET} ${dep.service} (infrastructure — not in catalog)`); + continue; + } + if (req) { + writeLine(` ${colors.red}fail${RESET} ${dep.service} — not found in catalog`); + allGreen = false; + } else { + writeLine(` ${colors.yellow}warn${RESET} ${dep.service} — not in catalog (optional)`); + } + continue; + } + + // Check health of dependency + if (!opts.skipHealth && depService.spec.health.length > 0) { + for (const h of depService.spec.health) { + const result = await checkHealth(h.url, h.expect); + if (result.ok) { + writeLine(` ${colors.green}pass${RESET} ${dep.service}/${h.name} — ${result.status}`); + } else if (req) { + writeLine(` ${colors.red}fail${RESET} ${dep.service}/${h.name} — ${result.status}`); + allGreen = false; + } else { + writeLine(` ${colors.yellow}warn${RESET} ${dep.service}/${h.name} — ${result.status} (optional)`); + } + } + } else { + writeLine(` ${colors.dim}skip${RESET} ${dep.service} health check (${opts.skipHealth ? 'skipped' : 'no endpoints'})`); + } + } + } + + writeLine(); + + // Check deploy order from graph + if (graph) { + const order = graph.deploy_order; + let servicePhase = -1; + for (let i = 0; i < order.length; i++) { + if (order[i].includes(serviceName)) { + servicePhase = i; + break; + } + } + + if (servicePhase >= 0) { + writeLine(` ${colors.cyan}Deploy Order${RESET}`); + for (let i = 0; i < order.length; i++) { + const marker = i === servicePhase ? `${colors.green}→${RESET}` : ' '; + const phase = order[i].join(', '); + writeLine(` ${marker} Phase ${i + 1}: ${i === servicePhase ? bold : colors.dim}${phase}${RESET}`); + } + writeLine(); + } + } + + // Self health check + if (!opts.skipHealth && service.spec.health.length > 0) { + writeLine(` ${colors.cyan}Self Health${RESET}`); + for (const h of service.spec.health) { + const result = await checkHealth(h.url, h.expect); + if (result.ok) { + writeLine(` ${colors.green}pass${RESET} ${h.name} — ${result.status}`); + } else { + writeLine(` ${colors.yellow}warn${RESET} ${h.name} — ${result.status}`); + } + } + writeLine(); + } + + // Summary + if (allGreen) { + writeLine(` ${colors.green}All checks passed — safe to deploy ${serviceName}${RESET}`); + } else { + writeLine(` ${colors.red}Pre-check failed — fix issues before deploying ${serviceName}${RESET}`); + } + writeLine(); + }); +} diff --git a/src/lib/agent-runner.ts b/src/lib/agent-runner.ts index 5b4d547..e073fc3 100644 --- a/src/lib/agent-runner.ts +++ b/src/lib/agent-runner.ts @@ -44,8 +44,8 @@ import { extractMcpServersFromDefinition, loadSystemProtocol, gatherSquadContext, + resolveContextRoleFromAgent, } from './run-context.js'; -import { classifyAgent } from './conversation.js'; import { buildContextFromSquad, validateExecution, @@ -72,7 +72,7 @@ import { findMemoryDir } from './memory.js'; // ── Operational constants (no magic numbers) ────────────────────────── export const DRYRUN_DEF_MAX_CHARS = 500; -export const DRYRUN_CONTEXT_MAX_CHARS = 800; +export const DRYRUN_CONTEXT_MAX_CHARS = parseInt(process.env.SQUADS_DRYRUN_MAX_CHARS || '800', 10); export async function runAgent( agentName: string, @@ -97,9 +97,9 @@ export async function runAgent( if (options.dryRun) { spinner.info(`[DRY RUN] Would run ${agentName}`); // Show context that would be injected (with role-based gating) - const dryRunAgentRole = classifyAgent(agentName); - const dryRunContextRole: ContextRole = agentName.includes('company-lead') ? 'coo' - : (dryRunAgentRole as ContextRole | null) ?? 'worker'; + const dryRunContextRole: ContextRole = agentName.includes('company-lead') + ? 'coo' + : resolveContextRoleFromAgent(agentPath, agentName); const dryRunContext = gatherSquadContext(squadName, agentName, { verbose: options.verbose, agentPath, role: dryRunContextRole }); @@ -225,10 +225,11 @@ export async function runAgent( const systemProtocol = loadSystemProtocol(); const systemContext = systemProtocol ? `\n${systemProtocol}\n` : ''; - // Derive context role from agent name for role-based context gating - const agentRole = classifyAgent(agentName); - const contextRole: ContextRole = agentName.includes('company-lead') ? 'coo' - : (agentRole as ContextRole | null) ?? 'worker'; + // Derive context role from the agent's own YAML frontmatter `role:` free-text. + // Company COO override remains explicit. + const contextRole: ContextRole = agentName.includes('company-lead') + ? 'coo' + : resolveContextRoleFromAgent(agentPath, agentName); // Gather squad context (role-based: scanners get minimal, leads get everything) const squadContext = gatherSquadContext(squadName, agentName, { @@ -264,27 +265,17 @@ export async function runAgent( const taskDirective = options.task ? `\n## TASK DIRECTIVE (overrides default behavior)\n${options.task}\n` : ''; - const prompt = `Execute the ${agentName} agent from squad ${squadName}. - -Read the agent definition at ${agentPath} and follow its instructions exactly. + const prompt = `You are ${agentName} from squad ${squadName}. ${taskDirective} -The agent definition contains: -- Purpose/role -- Tools it can use (MCP servers, skills) -- Step-by-step instructions -- Expected output format - -TOOL PREFERENCE: Always prefer CLI tools over MCP servers when both can accomplish the task: -- Use \`squads\` CLI for squad operations (run, memory, status, feedback) -- Use \`gh\` CLI for GitHub (issues, PRs, repos) -- Use \`git\` CLI for version control -- Use Bash for file operations, builds, tests -- Only use MCP tools when CLI cannot do it or MCP is significantly better +Your full context follows — read it top-to-bottom. Each layer builds on the previous: +- SYSTEM.md: how the system works (already loaded) +- Company: who we are and why +- Priorities: where to focus now +- Goals: what to achieve (measurable targets) +- Agent: your specific role and instructions +- State: where you left off ${systemContext}${squadContext}${cognitionContext}${learningContext} -TIME LIMIT: You have ${timeoutMins} minutes. Work efficiently: -- Focus on the most important tasks first -- If a task is taking too long, move on and note it for next run -- Aim to complete within ${Math.floor(timeoutMins * SOFT_DEADLINE_RATIO)} minutes`; +TIME LIMIT: ${timeoutMins} minutes. Focus on priorities first. If blocked, note it in state.md and move on.`; // Resolve provider with full chain: // 1. Agent config (from agent file frontmatter/header) diff --git a/src/lib/idp/catalog-loader.ts b/src/lib/idp/catalog-loader.ts new file mode 100644 index 0000000..35a0d3a --- /dev/null +++ b/src/lib/idp/catalog-loader.ts @@ -0,0 +1,66 @@ +/** + * Catalog loader — reads and parses YAML catalog entries from the IDP repo. + */ + +import { readdirSync, readFileSync, existsSync } from 'fs'; +import { join } from 'path'; +import matter from 'gray-matter'; +import { findIdpDir } from './resolver.js'; +import type { CatalogEntry, ScorecardDefinition, DependencyGraph } from './types.js'; + +/** Parse a YAML file using gray-matter's YAML engine */ +function loadYaml(filePath: string): T | null { + if (!existsSync(filePath)) return null; + try { + const raw = readFileSync(filePath, 'utf-8'); + // gray-matter parses YAML frontmatter — wrap raw YAML so it treats entire file as frontmatter + const { data } = matter(`---\n${raw}\n---`); + return data as T; + } catch { + return null; + } +} + +/** Load all catalog entries from the IDP catalog/ directory */ +export function loadCatalog(): CatalogEntry[] { + const idpDir = findIdpDir(); + if (!idpDir) return []; + + const catalogDir = join(idpDir, 'catalog'); + if (!existsSync(catalogDir)) return []; + + const entries: CatalogEntry[] = []; + for (const file of readdirSync(catalogDir).filter(f => f.endsWith('.yaml')).sort()) { + const entry = loadYaml(join(catalogDir, file)); + if (entry?.metadata?.name) { + entries.push(entry); + } + } + return entries; +} + +/** Load a single catalog entry by service name */ +export function loadService(name: string): CatalogEntry | null { + const idpDir = findIdpDir(); + if (!idpDir) return null; + + const filePath = join(idpDir, 'catalog', `${name}.yaml`); + return loadYaml(filePath); +} + +/** Load a scorecard definition by name */ +export function loadScorecard(name: string): ScorecardDefinition | null { + const idpDir = findIdpDir(); + if (!idpDir) return null; + + const filePath = join(idpDir, 'scorecards', `${name}.yaml`); + return loadYaml(filePath); +} + +/** Load the dependency graph */ +export function loadDependencyGraph(): DependencyGraph | null { + const idpDir = findIdpDir(); + if (!idpDir) return null; + + return loadYaml(join(idpDir, 'dependencies', 'graph.yaml')); +} diff --git a/src/lib/idp/resolver.ts b/src/lib/idp/resolver.ts new file mode 100644 index 0000000..3520257 --- /dev/null +++ b/src/lib/idp/resolver.ts @@ -0,0 +1,45 @@ +/** + * IDP directory resolver — finds the IDP repo/directory. + * + * Resolution order: + * 1. SQUADS_IDP_PATH env var (explicit override) + * 2. .agents/idp/ in project root (co-located) + * 3. ../idp/ sibling repo (our setup) + * 4. ~/agents-squads/idp/ (absolute fallback) + */ + +import { existsSync } from 'fs'; +import { join, resolve } from 'path'; +import { findProjectRoot } from '../squad-parser.js'; + +export function findIdpDir(): string | null { + // 1. Explicit env var + const envPath = process.env.SQUADS_IDP_PATH; + if (envPath && existsSync(envPath)) { + return resolve(envPath); + } + + // 2. Co-located in project + const projectRoot = findProjectRoot(); + if (projectRoot) { + const colocated = join(projectRoot, '.agents', 'idp'); + if (existsSync(join(colocated, 'catalog'))) { + return colocated; + } + + // 3. Sibling repo + const sibling = join(projectRoot, '..', 'idp'); + if (existsSync(join(sibling, 'catalog'))) { + return resolve(sibling); + } + } + + // 4. Absolute fallback + const home = process.env.HOME || process.env.USERPROFILE || ''; + const absolute = join(home, 'agents-squads', 'idp'); + if (existsSync(join(absolute, 'catalog'))) { + return absolute; + } + + return null; +} diff --git a/src/lib/idp/scorecard-engine.ts b/src/lib/idp/scorecard-engine.ts new file mode 100644 index 0000000..5a100c7 --- /dev/null +++ b/src/lib/idp/scorecard-engine.ts @@ -0,0 +1,215 @@ +/** + * Scorecard engine — evaluates services against quality checks. + * + * Sources data from: + * - Local filesystem (README exists, build works) + * - gh CLI (CI status, PRs, security alerts) — graceful if missing + * - Git log (deploy frequency, recent activity) + */ + +import { existsSync, readFileSync, statSync } from 'fs'; +import { join } from 'path'; +import { execSync } from 'child_process'; +import type { CatalogEntry, ScorecardDefinition, ScorecardResult } from './types.js'; + +function exec(cmd: string, cwd?: string): string | null { + try { + return execSync(cmd, { encoding: 'utf-8', timeout: 15000, cwd, stdio: ['pipe', 'pipe', 'pipe'] }).trim(); + } catch { + return null; + } +} + +function ghAvailable(): boolean { + return exec('gh --version') !== null; +} + +interface CheckResult { + name: string; + passed: boolean; + weight: number; + detail: string; +} + +function runCheck( + check: ScorecardDefinition['checks'][0], + service: CatalogEntry, + repoPath: string | null +): CheckResult { + const result: CheckResult = { name: check.name, passed: false, weight: check.weight, detail: 'unknown' }; + const repo = service.metadata.repo; + + switch (check.name) { + case 'ci-passing': { + if (!ghAvailable()) { result.detail = 'gh CLI not available'; break; } + const out = exec(`gh api repos/${repo}/actions/runs?per_page=1&status=completed --jq '.[0].conclusion // empty'`); + // GitHub API returns runs array directly + const out2 = exec(`gh api repos/${repo}/actions/runs --jq '.workflow_runs[0].conclusion // empty'`); + const conclusion = out || out2; + if (conclusion === 'success') { result.passed = true; result.detail = 'latest run: success'; } + else if (conclusion) { result.detail = `latest run: ${conclusion}`; } + else { result.detail = 'no CI runs found'; } + break; + } + + case 'test-coverage': { + // Would need CI output parsing — for v0.1, check if test command exists + if (service.spec.ci.test_command && service.spec.ci.test_command !== 'null') { + result.passed = true; + result.detail = `test command defined: ${service.spec.ci.test_command}`; + } else { + result.detail = 'no test command configured'; + } + break; + } + + case 'build-succeeds': { + if (repoPath && service.spec.ci.build_command) { + const buildResult = exec(`cd "${repoPath}" && ${service.spec.ci.build_command} 2>&1`); + if (buildResult !== null) { result.passed = true; result.detail = 'build passed'; } + else { result.detail = 'build failed'; } + } else { + result.detail = repoPath ? 'no build command' : 'repo not found locally'; + } + break; + } + + case 'no-security-alerts': { + if (!ghAvailable()) { result.detail = 'gh CLI not available'; break; } + const alerts = exec(`gh api repos/${repo}/dependabot/alerts --jq '[.[] | select(.state=="open" and (.security_advisory.severity=="high" or .security_advisory.severity=="critical"))] | length'`); + if (alerts === '0') { result.passed = true; result.detail = 'no high/critical alerts'; } + else if (alerts) { result.detail = `${alerts} high/critical alerts`; } + else { result.detail = 'could not check alerts'; } + break; + } + + case 'readme-exists': { + if (repoPath) { + const readmePath = join(repoPath, 'README.md'); + if (existsSync(readmePath)) { + const size = statSync(readmePath).size; + if (size > 100) { result.passed = true; result.detail = `README.md (${size} bytes)`; } + else { result.detail = `README.md too short (${size} bytes)`; } + } else { + result.detail = 'README.md not found'; + } + } else { + result.detail = 'repo not found locally'; + } + break; + } + + case 'branch-protection': { + if (!ghAvailable()) { result.detail = 'gh CLI not available'; break; } + const protection = exec(`gh api repos/${repo}/branches/${service.spec.branches.default}/protection --jq '.required_status_checks.strict // false' 2>/dev/null`); + if (protection && protection !== 'null') { result.passed = true; result.detail = 'branch protection enabled'; } + else { result.detail = 'no branch protection'; } + break; + } + + case 'deploy-frequency': { + if (!ghAvailable()) { result.detail = 'gh CLI not available'; break; } + const runs = exec(`gh api repos/${repo}/actions/runs --jq '[.workflow_runs[] | select(.event=="push" and .head_branch=="${service.spec.branches.default}")] | length'`); + const count = parseInt(runs || '0', 10); + if (count > 0) { result.passed = true; result.detail = `${count} deploys recently`; } + else { result.detail = 'no recent deploys'; } + break; + } + + case 'stale-prs': { + if (!ghAvailable()) { result.detail = 'gh CLI not available'; break; } + const stalePrs = exec(`gh pr list --repo ${repo} --state open --json updatedAt --jq '[.[] | select((now - (.updatedAt | fromdateiso8601)) > 1209600)] | length'`); + const count = parseInt(stalePrs || '0', 10); + if (count === 0) { result.passed = true; result.detail = 'no stale PRs'; } + else { result.detail = `${count} PRs stale >14d`; } + break; + } + + case 'recent-activity': { + if (repoPath) { + const commits = exec(`git -C "${repoPath}" log --since="30 days ago" --oneline 2>/dev/null | wc -l`); + const count = parseInt(commits?.trim() || '0', 10); + if (count > 0) { result.passed = true; result.detail = `${count} commits in last 30d`; } + else { result.detail = 'no commits in 30 days'; } + } else if (ghAvailable()) { + const out = exec(`gh api repos/${repo}/commits?per_page=1 --jq '.[0].commit.committer.date // empty'`); + if (out) { result.passed = true; result.detail = `last commit: ${out.slice(0, 10)}`; } + else { result.detail = 'no recent commits'; } + } else { + result.detail = 'repo not found locally'; + } + break; + } + + case 'no-stale-prs': { + if (!ghAvailable()) { result.detail = 'gh CLI not available'; break; } + const stalePrs = exec(`gh pr list --repo ${repo} --state open --json updatedAt --jq '[.[] | select((now - (.updatedAt | fromdateiso8601)) > 604800)] | length'`); + const count = parseInt(stalePrs || '0', 10); + if (count === 0) { result.passed = true; result.detail = 'no stale PRs'; } + else { result.detail = `${count} PRs stale >7d`; } + break; + } + + case 'clean-structure': { + // For domain repos — check no binaries or misplaced files in root + result.passed = true; + result.detail = 'check not implemented (v0.2)'; + break; + } + + default: + result.detail = `unknown check: ${check.name}`; + } + + return result; +} + +/** Find the local path for a repo */ +function findRepoPath(repoFullName: string): string | null { + const repoName = repoFullName.split('/')[1]; + if (!repoName) return null; + + const home = process.env.HOME || ''; + const candidates = [ + join(home, 'agents-squads', repoName), + join(process.cwd(), '..', repoName), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + return null; +} + +/** Run all scorecard checks for a service */ +export function evaluateService( + service: CatalogEntry, + scorecard: ScorecardDefinition +): ScorecardResult { + const repoPath = findRepoPath(service.metadata.repo); + const checks: CheckResult[] = []; + + for (const check of scorecard.checks) { + checks.push(runCheck(check, service, repoPath)); + } + + const totalWeight = checks.reduce((sum, c) => sum + c.weight, 0); + const earnedWeight = checks.filter(c => c.passed).reduce((sum, c) => sum + c.weight, 0); + const score = totalWeight > 0 ? Math.round((earnedWeight / totalWeight) * 100) : 0; + + // Determine grade + let grade = 'F'; + const sortedGrades = Object.entries(scorecard.grades).sort((a, b) => b[1].min - a[1].min); + for (const [g, { min }] of sortedGrades) { + if (score >= min) { grade = g; break; } + } + + return { + service: service.metadata.name, + scorecard: scorecard.metadata.name, + score, + grade, + checks, + timestamp: new Date().toISOString(), + }; +} diff --git a/src/lib/idp/types.ts b/src/lib/idp/types.ts new file mode 100644 index 0000000..18dc1d1 --- /dev/null +++ b/src/lib/idp/types.ts @@ -0,0 +1,113 @@ +/** + * IDP type definitions — mirrors the YAML schema in the idp/ repo. + */ + +export interface CatalogEntry { + apiVersion: string; + kind: 'Service'; + metadata: { + name: string; + description: string; + owner: string; + repo: string; + tags: string[]; + }; + spec: { + type: 'product' | 'domain'; + stack: string; + framework?: string; + runtime?: string; + language_version?: string; + branches: { + default: string; + development?: string | null; + workflow: 'pr-to-develop' | 'direct-to-main'; + }; + ci: { + template: string | null; + required_checks: string[]; + test_command?: string | null; + build_command?: string | null; + coverage_threshold?: number; + }; + deploy?: { + target: string; + trigger: string; + pipeline?: string; + environments?: Array<{ + name: string; + url: string; + }>; + } | null; + health: Array<{ + name: string; + url: string; + type: 'http' | 'json'; + expect: number; + }>; + dependencies: { + runtime: Array<{ + service: string; + version?: string; + type?: string; + required?: boolean; + description: string; + }>; + }; + scorecard: string; + }; +} + +export interface ScorecardDefinition { + apiVersion: string; + kind: 'Scorecard'; + metadata: { + name: string; + description: string; + }; + checks: Array<{ + name: string; + description: string; + weight: number; + source: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + threshold?: { + min: number; + unit: string; + }; + }>; + grades: Record; +} + +export interface DependencyGraph { + apiVersion: string; + kind: 'DependencyGraph'; + metadata: { + name: string; + description: string; + updated: string; + }; + edges: Array<{ + consumer: string; + provider: string; + type: string; + required?: boolean; + contract?: string; + description: string; + }>; + deploy_order: string[][]; +} + +export interface ScorecardResult { + service: string; + scorecard: string; + score: number; + grade: string; + checks: Array<{ + name: string; + passed: boolean; + weight: number; + detail: string; + }>; + timestamp: string; +} diff --git a/src/lib/run-context.ts b/src/lib/run-context.ts index 5e0a420..33c1c00 100644 --- a/src/lib/run-context.ts +++ b/src/lib/run-context.ts @@ -1,54 +1,54 @@ /** * run-context.ts * - * Helpers for building agent execution context and parsing agent definitions. - * Extracted from src/commands/run.ts to reduce its size. + * Squad Context System — context assembly for agent execution. * - * Context cascade (role-based, priority-ordered): - * SYSTEM.md (immutable, outside budget) - * 1. SQUAD.md — mission + goals + output format - * 2. priorities.md — current operational priorities - * 3. directives.md — company-wide strategic overlay - * 4. feedback.md — last cycle evaluation - * 5. state.md — agent's memory from last execution - * 6. active-work.md — open PRs and issues - * 7. Agent briefs — agent-level briefing files - * 8. Squad briefs — squad-level briefing files - * 9. Daily briefing — org-wide daily briefing - * 10. Cross-squad learnings — shared learnings from other squads + * Layers flow from general to particular (no overrides, each answers a different question): + * L0: SYSTEM.md — How (system, tools, principles — immutable, outside budget) + * L1: company.md — Why (company identity, alignment) + * L2: priorities.md — Where (current focus, urgency) + * L3: goals.md — What (measurable targets) + * L4: agent.md — You (agent role, specific instructions) + * L5: state.md — Memory (continuity from last run) + * L6+: Supporting — feedback, daily-briefing, cross-squad learnings * - * Sections load in priority order. When budget is exhausted, later sections drop. - * Role determines which sections are included and the total token budget. + * SQUAD.md is metadata only (repo, agents, config) — NOT injected into prompt. + * Each layer adds a unique dimension. No layer contradicts another. + * Role determines which layers are included and the total token budget. */ import { join, dirname } from 'path'; import { existsSync, readFileSync, readdirSync } from 'fs'; +import { execSync } from 'child_process'; import { findSquadsDir } from './squad-parser.js'; import { findMemoryDir } from './memory.js'; import { colors, RESET, writeLine } from './terminal.js'; // ── Types ──────────────────────────────────────────────────────────── -export type ContextRole = 'scanner' | 'worker' | 'lead' | 'coo'; +export type ContextRole = 'scanner' | 'worker' | 'lead' | 'coo' | 'verifier'; // ── Token Budgets (chars, ~4 chars/token) ──────────────────────────── const ROLE_BUDGETS: Record = { - scanner: 4000, // ~1000 tokens — identity + priorities + state - worker: 12000, // ~3000 tokens — + directives, feedback, active-work - lead: 24000, // ~6000 tokens — all sections - coo: 32000, // ~8000 tokens — all sections + expanded + scanner: 4000, // ~1000 tokens — company + priorities + goals + agent + state + worker: 12000, // ~3000 tokens — + feedback + lead: 24000, // ~6000 tokens — all layers + coo: 32000, // ~8000 tokens — all layers + expanded + verifier: 12000, // similar needs to worker }; /** - * Which sections each role gets access to. - * Numbers correspond to section order in the cascade. + * Which layers each role gets access to. + * Numbers correspond to layer order in the Squad Context System: + * 1=company, 2=priorities, 3=goals, 4=agent, 5=state, 6=feedback, 7=daily-briefing, 8=cross-squad */ const ROLE_SECTIONS: Record> = { - scanner: new Set([1, 2, 5]), // SQUAD.md, priorities, state - worker: new Set([1, 2, 3, 4, 5, 6]), // + directives, feedback, active-work - lead: new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), // all sections - coo: new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]), // all sections + expanded budget + scanner: new Set([1, 2, 3, 4, 5]), // identity + focus + role + memory + worker: new Set([1, 2, 3, 4, 5, 6]), // + feedback + lead: new Set([1, 2, 3, 4, 5, 6, 7, 8]), // + daily briefing + cross-squad + coo: new Set([1, 2, 3, 4, 5, 6, 7, 8]), // all layers + expanded budget + verifier: new Set([1, 2, 3, 4, 5, 6]), // same as worker }; // ── Agent Frontmatter ───────────────────────────────────────────────── @@ -61,6 +61,11 @@ export interface AgentFrontmatter { acceptance_criteria?: string; max_retries?: number; cooldown?: string; + /** + * `role:` field from agent YAML frontmatter (free text). + * Used as the primary signal for context-role selection. + */ + agent_role?: string; } /** @@ -121,6 +126,25 @@ export function parseAgentFrontmatter(agentPath: string): AgentFrontmatter { result.cooldown = cooldownMatch[1].trim(); } + // role: + // Primary signal for mapping to context role (scanner/worker/lead/verifier). + for (const line of yamlLines) { + const trimmed = line.trim(); + if (!trimmed.startsWith('role:')) continue; + let value = trimmed.slice('role:'.length).trim(); + // Strip wrapping quotes if present. + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith('\'') && value.endsWith('\'')) + ) { + value = value.slice(1, -1).trim(); + } + if (value) { + result.agent_role = value; + } + break; + } + return result; } @@ -187,18 +211,43 @@ function readAgentsFile(relativePath: string, warnLabel: string): string { // ── System Protocol ─────────────────────────────────────────────────── /** - * Load SYSTEM.md — the single base protocol for all agents. - * Replaces the old approval-instructions.md + post-execution.md split. - * Falls back to legacy approval-instructions.md if SYSTEM.md doesn't exist. + * Load SYSTEM.md (L0) — the immutable base protocol for all agents. + * Path: .agents/SYSTEM.md (top-level, next to squads/ and memory/) + * Falls back to legacy config/SYSTEM.md, then approval-instructions.md. */ export function loadSystemProtocol(): string { - const systemMd = readAgentsFile('config/SYSTEM.md', 'SYSTEM.md'); + // Primary: .agents/SYSTEM.md + const systemMd = readAgentsFile('SYSTEM.md', 'SYSTEM.md'); if (systemMd) return systemMd; - // Fallback to legacy approval-instructions.md + // Fallback: legacy path + const legacyMd = readAgentsFile('config/SYSTEM.md', 'SYSTEM.md (legacy)'); + if (legacyMd) return legacyMd; + return loadApprovalInstructions(); } +/** + * Load company.md (L1) — company context and strategic direction. + * Path: .agents/company.md + * This is the "why" layer — frames everything that follows. + */ +export function loadCompanyContext(): string { + // Primary: .agents/company.md + const companyMd = readAgentsFile('company.md', 'company.md'); + if (companyMd) return companyMd; + + // Fallback: legacy directives.md (for backward compat during migration) + const memoryDir = findMemoryDir(); + if (memoryDir) { + const directivesFile = join(memoryDir, 'company', 'directives.md'); + const content = safeRead(directivesFile); + if (content) return content; + } + + return ''; +} + /** * Legacy: load approval instructions. Kept for backward compat — prefer SYSTEM.md. * @deprecated Absorbed into SYSTEM.md. Used as fallback when SYSTEM.md absent. @@ -232,6 +281,112 @@ function safeRead(path: string): string { } } +function stripYamlFrontmatter(markdown: string): string { + const lines = markdown.split('\n'); + let dashCount = 0; + let endIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].trim() === '---') { + dashCount++; + if (dashCount === 2) { + endIdx = i; + break; + } + } + } + if (endIdx >= 0) return lines.slice(endIdx + 1).join('\n').trim(); + return markdown.trim(); +} + +function scoreByTokens(text: string, tokens: string[]): number { + const lower = text.toLowerCase(); + let score = 0; + for (const t of tokens) { + if (!t) continue; + if (lower.includes(t)) score += 1; + } + return score; +} + +/** + * Primary context-role resolver. + * + * Uses the agent YAML frontmatter `role:` free-text as the signal. + * Only when ambiguous and enabled (env var) will it ask an LLM to pick + * one of: scanner | worker | lead | verifier. + */ +export function resolveContextRoleFromAgent(agentPath: string, agentName: string): ContextRole { + const fm = parseAgentFrontmatter(agentPath); + const roleText = fm.agent_role || ''; + const normalized = roleText.trim().toLowerCase(); + + // Direct match — new structured schema uses exact role values + const directRoles: ContextRole[] = ['scanner', 'worker', 'lead', 'verifier']; + for (const r of directRoles) { + if (normalized === r) return r; + } + // COO is a lead with expanded budget + if (normalized === 'coo') return 'coo'; + + // Deterministic mapping from role text. Avoids brittle regex coupling. + const scannerTokens = ['scan', 'monitor', 'detect', 'find', 'opportun', 'scout', 'gap', 'bottleneck']; + const workerTokens = ['execute', 'implement', 'write', 'create', 'build', 'prototype', 'file', 'issue', 'worker']; + const leadTokens = ['lead', 'orchestrate', 'own', 'strategy', 'roadmap', 'coordinate', 'triage', 'review', 'mvp']; + const verifierTokens = ['verify', 'validation', 'compliance', 'audit', 'approve', 'reject', 'check', 'test', 'critic', 'verifier']; + + const scored: Array<[ContextRole, number]> = [ + ['scanner', scoreByTokens(normalized, scannerTokens)], + ['worker', scoreByTokens(normalized, workerTokens)], + ['lead', scoreByTokens(normalized, leadTokens)], + ['verifier', scoreByTokens(normalized, verifierTokens)], + ]; + + scored.sort((a, b) => b[1] - a[1]); + const best = scored[0]; + const second = scored[1]; + + // Clean mapping => unique non-zero best score. + const clean = best[1] > 0 && (!second || second[1] === 0); + if (clean) return best[0]; + + const llmEnabled = process.env.SQUADS_CONTEXT_ROLE_LLM === '1'; + if (!llmEnabled) return 'worker'; + + // LLM fallback: best-effort classification. If it fails, return worker. + try { + const raw = safeRead(agentPath); + const body = stripYamlFrontmatter(raw); + const excerpt = body.slice(0, 1600); + + const prompt = [ + 'Classify the agent into exactly ONE Agents Squads context role.', + 'Return EXACTLY one token from: scanner, worker, lead, verifier.', + '', + `Agent name: ${agentName}`, + `Agent frontmatter role: ${roleText || '(missing)'}`, + '', + 'Agent definition excerpt:', + excerpt, + ].join('\n'); + + const escapedPrompt = prompt.replace(/'/g, "'\\''"); + const model = process.env.SQUADS_CONTEXT_ROLE_LLM_MODEL || 'claude-haiku-4-5'; + const out = execSync( + `claude --print --dangerously-skip-permissions --disable-slash-commands --model ${model} -- '${escapedPrompt}'`, + { encoding: 'utf-8', timeout: 60_000, maxBuffer: 2 * 1024 * 1024 } + ).trim().toLowerCase(); + + const tokens: ContextRole[] = ['scanner', 'worker', 'lead', 'verifier']; + for (const t of tokens) { + if (out === t || out.includes(t)) return t; + } + + return 'worker'; + } catch { + return 'worker'; + } +} + /** Read all .md files from a directory, concatenated */ function readDirMd(dirPath: string, maxChars: number): string { if (!existsSync(dirPath)) return ''; @@ -252,13 +407,22 @@ function readDirMd(dirPath: string, maxChars: number): string { } } -// ── Squad Context Assembly ──────────────────────────────────────────── +// ── Squad Context System Assembly ───────────────────────────────────── /** - * Gather squad context for prompt injection. + * Gather context for agent execution. + * + * Layers flow general → particular (each adds a unique dimension): + * 1. company.md — Why (company identity, alignment) + * 2. priorities.md — Where (current focus, urgency) + * 3. goals.md — What (measurable targets) + * 4. agent.md — You (agent role, instructions) + * 5. state.md — Memory (continuity from last run) + * 6. feedback.md — Supporting (squad feedback) + * 7. daily-briefing — Supporting (org pulse, leads+coo only) + * 8. cross-squad — Supporting (learnings from other squads) * - * Role-based context cascade (10 sections, priority-ordered): - * Sections load in order until the token budget is exhausted. + * SQUAD.md is NOT injected — it's metadata for the CLI (repo, agents, config). * Missing files are skipped gracefully — no crashes on first run or new squads. */ export function gatherSquadContext( @@ -271,26 +435,26 @@ export function gatherSquadContext( const memoryDir = findMemoryDir(); const role = options.role || 'worker'; - const budget = options.maxTokens ? options.maxTokens * 4 : ROLE_BUDGETS[role]; - const allowedSections = ROLE_SECTIONS[role]; + const budget = options.maxTokens ? options.maxTokens * 4 : (ROLE_BUDGETS[role] ?? ROLE_BUDGETS.worker); + const allowedSections = ROLE_SECTIONS[role] ?? ROLE_SECTIONS.worker; const sections: string[] = []; let usedChars = 0; - /** Try to add a section. Returns true if added, false if budget exceeded or not allowed. */ - function addSection(sectionNum: number, header: string, content: string, maxChars?: number): boolean { - if (!allowedSections.has(sectionNum)) return false; + /** Try to add a layer. Returns true if added, false if budget exceeded or not allowed. */ + function addLayer(layerNum: number, header: string, content: string, maxChars?: number): boolean { + if (!allowedSections.has(layerNum)) return false; if (!content) return false; let text = content; - const cap = maxChars || (budget - usedChars); + const remaining = Math.max(0, budget - usedChars); + const cap = maxChars !== undefined ? Math.min(maxChars, remaining) : remaining; if (text.length > cap) { text = text.substring(0, cap) + '\n...'; } if (usedChars + text.length > budget) { - // Budget exhausted — drop this and all later sections if (options.verbose) { - writeLine(` ${colors.dim}Context budget exhausted at section ${sectionNum} (${header})${RESET}`); + writeLine(` ${colors.dim}Context budget exhausted at layer ${layerNum} (${header})${RESET}`); } return false; } @@ -300,99 +464,72 @@ export function gatherSquadContext( return true; } - // ── Section 1: SQUAD.md ── - const squadFile = join(squadsDir, squadName, 'SQUAD.md'); - if (existsSync(squadFile)) { - try { - const content = readFileSync(squadFile, 'utf-8'); - // Extract mission section; fall back to first N chars - const missionMatch = content.match(/## Mission[\s\S]*?(?=\n## |$)/i); - const squad = missionMatch ? missionMatch[0] : content.substring(0, 2000); - addSection(1, `Squad: ${squadName}`, squad.trim()); - } catch (e) { - if (options.verbose) writeLine(` ${colors.dim}warn: failed reading SQUAD.md: ${e instanceof Error ? e.message : String(e)}${RESET}`); - } + // ── L1: company.md — Why (company identity, alignment) ── + const companyContext = loadCompanyContext(); + if (companyContext) { + addLayer(1, 'Company', stripYamlFrontmatter(companyContext)); } - // ── Section 2: priorities.md (fallback to goals.md for backward compat) ── + // ── L2: priorities.md — Where (current focus, urgency) ── if (memoryDir) { const prioritiesFile = join(memoryDir, squadName, 'priorities.md'); - const goalsFile = join(memoryDir, squadName, 'goals.md'); - const file = existsSync(prioritiesFile) ? prioritiesFile : goalsFile; - const content = safeRead(file); + const content = safeRead(prioritiesFile); if (content) { - addSection(2, 'Priorities', content); + addLayer(2, 'Priorities', stripYamlFrontmatter(content)); } } - // ── Section 3: directives.md ── + // ── L3: goals.md — What (measurable targets) ── if (memoryDir) { - const directivesFile = join(memoryDir, 'company', 'directives.md'); - const content = safeRead(directivesFile); + const goalsFile = join(memoryDir, squadName, 'goals.md'); + const content = safeRead(goalsFile); if (content) { - addSection(3, 'Directives', content); + addLayer(3, 'Goals', stripYamlFrontmatter(content)); } } - // ── Section 4: feedback.md ── - if (memoryDir) { - const feedbackFile = join(memoryDir, squadName, 'feedback.md'); - const content = safeRead(feedbackFile); - if (content) { - addSection(4, 'Feedback', content); + // ── L4: agent.md — You (agent role, instructions) ── + if (options.agentPath) { + const agentContent = safeRead(options.agentPath); + if (agentContent) { + // Strip YAML frontmatter — inject the markdown body only + const body = stripYamlFrontmatter(agentContent); + addLayer(4, `Agent: ${agentName}`, body); } } - // ── Section 5: state.md ── + // ── L5: state.md — Memory (continuity from last run) ── if (memoryDir) { const stateFile = join(memoryDir, squadName, agentName, 'state.md'); const content = safeRead(stateFile); if (content) { - // Scanner gets capped state, lead/coo get full - const stateCap = role === 'scanner' ? 2000 : undefined; - addSection(5, 'Previous State', content, stateCap); - } - } - - // ── Section 6: active-work.md ── - if (memoryDir) { - const activeWorkFile = join(memoryDir, squadName, 'active-work.md'); - const content = safeRead(activeWorkFile); - if (content) { - addSection(6, 'Active Work', content); + // Strip frontmatter — LLM gets the body (Current/Blockers/Carry Forward) + const body = stripYamlFrontmatter(content); + const stateCap = (role === 'scanner' || role === 'verifier') ? 2000 : undefined; + addLayer(5, 'Previous State', body, stateCap); } } - // ── Section 7: Agent briefs ── + // ── L6: feedback.md — Supporting (squad-level feedback) ── if (memoryDir) { - const briefsDir = join(memoryDir, squadName, agentName, 'briefs'); - const content = readDirMd(briefsDir, 3000); - if (content) { - addSection(7, 'Agent Briefs', content); - } - } - - // ── Section 8: Squad briefs ── - if (memoryDir) { - const briefsDir = join(memoryDir, squadName, '_briefs'); - const content = readDirMd(briefsDir, 3000); + const feedbackFile = join(memoryDir, squadName, 'feedback.md'); + const content = safeRead(feedbackFile); if (content) { - addSection(8, 'Squad Briefs', content); + addLayer(6, 'Feedback', content); } } - // ── Section 9: Daily briefing ── + // ── L7: Daily briefing — Supporting (org pulse, leads+coo only) ── if (memoryDir) { const dailyFile = join(memoryDir, 'daily-briefing.md'); const content = safeRead(dailyFile); if (content) { - addSection(9, 'Daily Briefing', content); + addLayer(7, 'Daily Briefing', content); } } - // ── Section 10: Cross-squad learnings ── + // ── L8: Cross-squad learnings — Supporting (from context_from agents) ── if (memoryDir) { - // Load from context_from squads if defined in agent frontmatter const frontmatter = options.agentPath ? parseAgentFrontmatter(options.agentPath) : {}; const contextSquads = frontmatter.context_from || []; const learningParts: string[] = []; @@ -404,14 +541,14 @@ export function gatherSquadContext( } } if (learningParts.length > 0) { - addSection(10, 'Cross-Squad Learnings', learningParts.join('\n\n')); + addLayer(8, 'Cross-Squad Learnings', learningParts.join('\n\n')); } } if (sections.length === 0) return ''; if (options.verbose) { - writeLine(` ${colors.dim}Context: ${sections.length} sections, ~${Math.ceil(usedChars / 4)} tokens (${role} role, budget: ~${Math.ceil(budget / 4)})${RESET}`); + writeLine(` ${colors.dim}Context: ${sections.length} layers, ~${Math.ceil(usedChars / 4)} tokens (${role} role, budget: ~${Math.ceil(budget / 4)})${RESET}`); } return `\n# CONTEXT\n${sections.join('\n\n')}\n`; diff --git a/test/docker/Dockerfile.fresh-user b/test/docker/Dockerfile.fresh-user new file mode 100644 index 0000000..e3a717c --- /dev/null +++ b/test/docker/Dockerfile.fresh-user @@ -0,0 +1,28 @@ +# Simulates a brand new user installing squads-cli for the first time. +# No config, no .agents dir, no history — completely clean environment. + +FROM node:22-slim + +RUN apt-get update && apt-get install -y git curl && rm -rf /var/lib/apt/lists/* + +# Install squads-cli globally (as root, like sudo npm install -g) +RUN npm install -g squads-cli 2>&1 || echo "INSTALL FAILED" + +# Verify it's installed +RUN which squads && squads --version || echo "squads not found after install" + +# Create non-root user +RUN useradd -m -s /bin/bash developer +USER developer +WORKDIR /home/developer + +RUN git config --global user.name "Test User" && \ + git config --global user.email "test@example.com" && \ + git config --global init.defaultBranch main + +# Fresh project +RUN mkdir -p /home/developer/my-project +WORKDIR /home/developer/my-project +RUN git init + +CMD ["/bin/bash"] diff --git a/test/docker/test-fresh-user.sh b/test/docker/test-fresh-user.sh new file mode 100755 index 0000000..65975c6 --- /dev/null +++ b/test/docker/test-fresh-user.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Test squads-cli as a brand new user in a clean Docker container. +# Runs the full first-run flow and reports pass/fail for each step. +# +# Usage: +# ./test/docker/test-fresh-user.sh # Interactive mode +# ./test/docker/test-fresh-user.sh --auto # Automated test suite +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLI_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" + +echo "=== Building fresh-user Docker image ===" +docker build -f "$SCRIPT_DIR/Dockerfile.fresh-user" -t squads-fresh-user "$CLI_DIR" + +if [ "${1:-}" = "--auto" ]; then + echo "" + echo "=== Running automated first-run test suite ===" + + docker run --rm squads-fresh-user bash -c ' + PASS=0 + FAIL=0 + + test_step() { + local name="$1" + shift + if "$@" > /tmp/output.txt 2>&1; then + echo " PASS $name" + PASS=$((PASS + 1)) + else + echo " FAIL $name" + echo " $(tail -3 /tmp/output.txt | head -3)" + FAIL=$((FAIL + 1)) + fi + } + + echo "" + echo "--- Step 1: squads --version ---" + test_step "version" squads --version + + echo "--- Step 2: squads --help ---" + test_step "help" squads --help + + echo "--- Step 3: squads init ---" + test_step "init" squads init + + echo "--- Step 4: .agents directory created ---" + test_step "agents-dir" test -d .agents/squads + + echo "--- Step 5: squads status ---" + test_step "status" squads status + + echo "--- Step 6: squads list ---" + test_step "list" squads list + + echo "--- Step 7: squads catalog list ---" + test_step "catalog-list" squads catalog list + + echo "--- Step 8: squads doctor ---" + test_step "doctor" squads doctor + + echo "--- Step 9: unknown command shows help ---" + test_step "unknown-cmd" bash -c "squads nonexistent 2>&1 | grep -qi help || squads nonexistent 2>&1 | grep -qi error" + + echo "" + echo "=== Results: $PASS passed, $FAIL failed ===" + + if [ "$FAIL" -gt 0 ]; then + exit 1 + fi + ' +else + echo "" + echo "=== Starting interactive fresh-user container ===" + echo "You are a new user. Try:" + echo " squads --version" + echo " squads init" + echo " squads status" + echo " squads catalog list" + echo "" + docker run --rm -it squads-fresh-user +fi