diff --git a/README.md b/README.md index 55c2b87..3835483 100644 --- a/README.md +++ b/README.md @@ -81,14 +81,6 @@ Five core commands plus an authoring command, one pipeline with clear division o All artifacts land in one project directory: `.claude/projects/{YYYY-MM-DD}-{slug}/` contains `brief.md`, `research/NN-*.md`, `architecture/` *(v2.2)*, `plan.md`, `sessions/`, and `progress.json`. `--project ` works across `/ultraresearch-local`, `/ultra-cc-architect-local`, `/ultraplan-local`, and `/ultraexecute-local`. -M0 of the runtime-profile work (additive, no behaviour change) introduces `profiles/*.yaml` and `scripts/profile-loader.mjs` — a null-deps Node loader with a limited-subset YAML parser and agent cross-validation (every referenced agent must exist as `agents/.md`). - -Phase 1 of `/ultraplan-local` now resolves a profile in the order `--profile flag → brief.recommended_profile → default fallback` and reports `Profile: {name} (source: ...)` in the mode banner. M0 ships only `default.yaml`, which mirrors the current hardcoded Phase 5/9 agent set verbatim — so existing flows are unaffected. - -The remaining milestones layer on top: M1 wires profile recommendation into `/ultrabrief-local` Phase 4 (brief recommends a profile, user confirms or overrides). M2 ships additional built-in profiles (`quick`, `bugfix`, `feature`, `refactor`, `security-deep`, `research-heavy`) and replaces the hardcoded Phase 5 agent table with profile-driven selection. M3 adds user-extensible profiles in `.claude/ultraplan-profiles/` and `~/.claude/ultraplan-profiles/`. - -The whole design preserves a universal brief: `/ultrabrief-local` asks the same questions regardless of domain, and the profile is a *processing decision* layered on top of that universal data capture. No silent variant routing, no hidden magic — the active profile is always reported and overridable. - v2.4.0 (breaking, default behavior) removes background mode from `/ultraplan-local`, `/ultraresearch-local`, `/ultra-cc-architect-local`, and `/ultrabrief-local` auto-mode. The commands now run foreground in the main context because the harness does not expose the Agent tool to sub-agents — background orchestrators silently degraded the swarm to inline reasoning without external research tools. The `--fg` flag is preserved as a no-op alias for backward compatibility. Source: github.com/anthropics/claude-code/issues/19077. v2.3 (non-breaking) ships the skill-factory Fase 1 MVP: `/ultra-skill-author-local` plus four supporting agents (1 opus orchestrator + 3 sonnet workers) and `scripts/ngram-overlap.mjs` (pure Node stdlib, word-5-gram containment + longest-run secondary signal, calibrated against three source/draft fixture pairs). Catalog growth is now tractable without touching the architect's hallucination gate. Non-goals stay explicit: no automation, no batch, no decision-layer skills, no remote sources — manual `mv` from `.drafts/` to catalog root is the promotion mechanism. v2.3.1 adds a qualified-slug convention (`[-]-.md`) so one feature can host multiple named patterns at different abstraction levels without displacing the baseline — resolved a collision surfaced in v2.3.0 dogfood. v2.3.2 closes the UX gap: `skill-drafter` now reads `{catalog_root}/.md` before writing and surfaces a collision warning in its confirmation output with a suggested qualified slug, so users see the overwrite risk before running `mv` — not after. diff --git a/plugins/ultraplan-local/CLAUDE.md b/plugins/ultraplan-local/CLAUDE.md index 7f948dd..3db0d01 100644 --- a/plugins/ultraplan-local/CLAUDE.md +++ b/plugins/ultraplan-local/CLAUDE.md @@ -119,19 +119,6 @@ Architect sits between `/ultraresearch-local` and `/ultraplan-local`. It matches **Security:** 4-layer defense-in-depth: plugin hooks (pre-bash-executor, pre-write-executor), prompt-level denylist (works in headless sessions), pre-execution plan scan (Phase 2.4), scoped `--allowedTools` replacing `--dangerously-skip-permissions`. Hard Rules 14-16 enforce verify command security, repo-boundary writes, and sensitive path protection. -**Profiles (M0 — foundation, no behaviour change):** Profiles describe -which exploration/review agents, catalog filter, and adversarial regime -ultraplan-local should use. They live as `profiles/*.yaml` and are loaded -by `scripts/profile-loader.mjs` (null-deps Node, limited-subset YAML -parser, agent cross-validation). M0 ships only `default.yaml`, which -captures today's hardcoded Phase 5/9 agent set verbatim — so existing -flows are unchanged. Phase 1 of `/ultraplan-local` now resolves a profile -in the order `--profile flag → brief frontmatter recommended_profile → -default fallback` and reports `Profile: {name} (source: ...)` in the mode -banner. Future milestones: M1 wires recommendation into ultrabrief Phase -4; M2 ships more built-in profiles + replaces the hardcoded agent table; -M3 adds user-extensible profiles under `.claude/ultraplan-profiles/`. - **Pipeline:** `/ultrabrief-local` produces the task brief. `/ultraresearch-local --project ` fills in `{dir}/research/`. `/ultra-cc-architect-local --project ` *(optional, v2.2)* matches available Claude Code features against brief+research and writes `{dir}/architecture/`. `/ultraplan-local --project ` reads brief + research (+ architecture note if present) to produce `{dir}/plan.md`. `/ultraexecute-local --project ` executes and writes `{dir}/progress.json`. All artifacts live in one project directory. **CC-feature catalog skill:** The architect phase loads the `cc-architect-catalog` skill, which indexes Claude Code primitives (hooks, subagents, skills, output styles, MCP, plan mode, worktrees, background agents) across three layers: `reference` (how a feature works), `pattern` (when to reach for it), `decision` (adoption heuristics). The `feature-matcher` agent only proposes features covered by the catalog *or* an explicit fallback list — a hallucination gate that `architecture-critic` enforces as BLOCKER severity. The `gap-identifier` agent emits issue-ready drafts for missing catalog entries so the catalog grows with real usage rather than speculation. The catalog lives at `skills/cc-architect-catalog/`. diff --git a/plugins/ultraplan-local/README.md b/plugins/ultraplan-local/README.md index 90ebd80..a7064da 100644 --- a/plugins/ultraplan-local/README.md +++ b/plugins/ultraplan-local/README.md @@ -266,39 +266,11 @@ Output: | **Research-enriched** | `/ultraplan-local --project --research ` | Add extra research briefs beyond what is in `research/` | | **Foreground** | `/ultraplan-local --project --fg` | No-op alias (foreground is default since v2.4.0) | | **Quick** | `/ultraplan-local --project --quick` | No agent swarm, lightweight scan only | -| **Profile** | `/ultraplan-local --project --profile ` | Explicit profile override (overrides `recommended_profile` in brief) | | **Decompose** | `/ultraplan-local --decompose plan.md` | Split plan into headless session specs | | **Export** | `/ultraplan-local --export pr plan.md` | PR description, issue comment, or clean markdown | `--brief` or `--project` is **required**. `/ultraplan-local` with no brief exits with an error and a pointer to `/ultrabrief-local`. -#### Profiles (M0 — foundation) - -A *profile* describes which exploration/review agents to spawn, which -catalog filter to apply, and which adversarial regime to use. Profiles -live in `profiles/*.yaml` and are loaded via -`scripts/profile-loader.mjs` (null-deps Node, limited-subset YAML -parser, validates that every referenced agent exists). - -M0 ships only the `default` profile, which mirrors the current -hardcoded Phase 5/9 agent set — so existing flows are unaffected. M1 -(coming next) lets `/ultrabrief-local` recommend a profile based on -brief content; M2 ships additional built-in profiles (`quick`, -`bugfix`, `feature`, `refactor`, `security-deep`, `research-heavy`) -and replaces the hardcoded Phase 5 agent table. M3 adds -user-extensible profiles in `.claude/ultraplan-profiles/`. - -```bash -# Inspect available profiles -node plugins/ultraplan-local/scripts/profile-loader.mjs list - -# Load a specific profile (JSON output) -node plugins/ultraplan-local/scripts/profile-loader.mjs load default - -# Validate a profile YAML -node plugins/ultraplan-local/scripts/profile-loader.mjs validate path/to/profile.yaml -``` - ### What the plan contains Every plan includes: diff --git a/plugins/ultraplan-local/commands/ultraplan-local.md b/plugins/ultraplan-local/commands/ultraplan-local.md index f31c507..c7ed18a 100644 --- a/plugins/ultraplan-local/commands/ultraplan-local.md +++ b/plugins/ultraplan-local/commands/ultraplan-local.md @@ -91,12 +91,7 @@ Parse `$ARGUMENTS` for mode flags. Order of precedence: 7. **`--quick`** — set **mode = quick**. Skip agent swarm; use lightweight Glob/Grep scan and go directly to planning + adversarial review. -8. **`--profile `** — explicit profile selection. Overrides any - `recommended_profile` from the brief frontmatter. Set - **profile_override = {name}**. Validation of the profile happens in the - profile-resolution step below; an unknown name aborts with a clear error. - -9. If neither `--brief` nor `--project` is present after flag parsing, +8. If neither `--brief` nor `--project` is present after flag parsing, output usage and stop: ``` @@ -105,7 +100,6 @@ Usage: /ultraplan-local --brief /ultraplan-local --brief --research /ultraplan-local --project --fg /ultraplan-local --project --quick - /ultraplan-local --project --profile /ultraplan-local --export /ultraplan-local --decompose @@ -117,8 +111,6 @@ Modes: --research Add up to 3 extra research briefs as planning context --fg No-op alias (foreground is the only mode as of v2.4.0) --quick Skip exploration agent swarm; plan directly - --profile Explicit profile (overrides brief's recommended_profile). - List available with: node profile-loader.mjs list --export Generate shareable output from an existing plan (no new planning) --decompose Split an existing plan into self-contained headless sessions @@ -127,7 +119,6 @@ Examples: /ultraplan-local --brief .claude/projects/2026-04-18-jwt-auth/brief.md /ultraplan-local --project .claude/projects/2026-04-18-jwt-auth --research extra.md /ultraplan-local --project .claude/projects/2026-04-18-jwt-auth --fg - /ultraplan-local --project .claude/projects/2026-04-18-jwt-auth --profile default /ultraplan-local --export pr .claude/plans/ultraplan-2026-04-06-rate-limiting.md /ultraplan-local --decompose .claude/plans/ultraplan-2026-04-06-rate-limiting.md @@ -153,52 +144,12 @@ If `research_status == pending` and `research_topics > 0`: - If cancel: print the research invocations from the brief's "How to continue" section and stop. -### Resolve the profile - -After reading the brief, determine which profile this plan run should use. -Profiles describe which exploration/review agents to spawn, which -adversarial regime to apply, and which catalog filter the architect should -use. The full schema lives in `${CLAUDE_PLUGIN_ROOT}/profiles/`; the loader -is `${CLAUDE_PLUGIN_ROOT}/scripts/profile-loader.mjs`. - -Resolution order (highest precedence first): - -1. **`--profile ` flag** — use it. If the profile cannot be loaded, - abort with a clear error listing available profiles: - ``` - Error: profile '{name}' not found. - Available: {comma-separated list from `profile-loader.mjs list`}. - Use --profile=default to fall back. - ``` -2. **Brief frontmatter `recommended_profile`** — if present, use it. -3. **Otherwise** — use `default`. - -If the resolved profile name is `default`, Phase 5 keeps its existing -size/conditional logic (convention-scanner only for medium+, research-scout -only for unfamiliar tech). For non-default profiles (M2 onward), Phase 5 -will run exactly the agents listed in `agents.exploration`. **In M0 only -`default` exists**, so resolution effectively always lands on `default` and -no behaviour changes. - -Load the resolved profile: - -``` -node ${CLAUDE_PLUGIN_ROOT}/scripts/profile-loader.mjs load {profile_name} -``` - -Capture the JSON output into **profile** state. If the loader exits non-zero, -abort with the loader's error text. - -Set **profile_source** to one of `flag | brief | default-fallback` for the -mode report below. - Report the detected mode: ``` Mode: {foreground | quick | export | decompose} Brief: {brief_path} Project: {project_dir or "-"} Research: {N local briefs, M extra via --research} -Profile: {profile.name} (source: {profile_source}) ``` ## Phase 1.5 — Export (runs only when mode = export) diff --git a/plugins/ultraplan-local/profiles/default.yaml b/plugins/ultraplan-local/profiles/default.yaml deleted file mode 100644 index 015e44e..0000000 --- a/plugins/ultraplan-local/profiles/default.yaml +++ /dev/null @@ -1,60 +0,0 @@ -# default.yaml — Bakoverkompatibel baseline profil for ultraplan-local -# -# Speiler dagens hardkodete oppførsel. Når ingen profil er valgt eksplisitt -# eller anbefalt av ultrabrief, faller ultraplan-local tilbake hit. Endring av -# denne filen endrer dagens oppførsel — gjør det med omhu. -# -# Schema-versjon: 1 (Profile Schema v1, dokumentert i README). - -name: default -description: "Bakoverkompatibel baseline — speiler dagens hardkodete oppførsel" -version: 1 - -# Aksene profilen sitter på. Brukes av profile-recommender for matching. -axes: - depth: standard - domain: general - goal: implementation - -# Stikkord som signalerer at denne profilen passer. default har ingen — -# den er fallback når ingenting annet matcher. -triggers: - keywords: [] - nfr_signals: [] - -# Hvilke agenter som kjøres i Phase 5 (exploration) og Phase 9 (review). -# For default-profilen kjører ultraplan-local sin opprinnelige -# size/conditional-logikk: convention-scanner kun for medium+ codebases, -# research-scout kun ved ekstern teknologi som ikke dekkes av research-briefer. -# Custom profiler kjører alle listede agenter uten conditional-filtering. -agents: - exploration: - - architecture-mapper - - dependency-tracer - - risk-assessor - - task-finder - - test-strategist - - git-historian - - convention-scanner - - research-scout - review: - - plan-critic - - scope-guardian - -# Filter for cc-architect-catalog ved /ultra-cc-architect-local. -# Tom liste = full katalog (dagens oppførsel). -skills: - catalog_filter: [] - prefer_layer: [] - -# Adversarial review-regime i Phase 9. -adversarial: - depth: standard - iterations: 1 - blockers_only: false - -# Override exploration-sizing fra settings.json. Default speiler verdiene der. -exploration_sizing: - smallCodebaseAgents: 3 - mediumCodebaseAgents: 5 - largeCodebaseAgents: 7 diff --git a/plugins/ultraplan-local/scripts/profile-loader.mjs b/plugins/ultraplan-local/scripts/profile-loader.mjs deleted file mode 100644 index 9b53f84..0000000 --- a/plugins/ultraplan-local/scripts/profile-loader.mjs +++ /dev/null @@ -1,451 +0,0 @@ -#!/usr/bin/env node -// profile-loader.mjs — Load and validate ultraplan-local profile YAML files. -// -// Profiles describe which agents, skills, and review regime ultraplan-local -// should use. They live in two locations: -// - Built-in: plugins/ultraplan-local/profiles/*.yaml -// - User (M3): .claude/ultraplan-profiles/ and ~/.claude/ultraplan-profiles/ -// -// M0 ships with built-in discovery only. -// -// The YAML parser here is intentionally a *limited* subset — enough for the -// profile schema, no more. Supports: -// - Top-level scalars (string, number, bool, null) -// - Nested mappings via 2-space indent -// - Block-style lists ('- item') -// - Inline lists ('[a, b, c]') -// - Quoted strings ("..." or '...') -// - Line comments (# at line start, or after whitespace) -// NOT supported: anchors/aliases, multi-line strings, flow mappings, -// tagged values. If you need those, the schema has drifted — fix the schema. -// -// Pure Node stdlib. No npm dependencies. -// -// CLI: -// node scripts/profile-loader.mjs list -// node scripts/profile-loader.mjs load - -import { readFile, readdir, stat } from 'node:fs/promises'; -import { join, dirname, basename } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { argv, exit, stdout, stderr } from 'node:process'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const PLUGIN_ROOT = join(__dirname, '..'); -const PROFILES_DIR = join(PLUGIN_ROOT, 'profiles'); -const AGENTS_DIR = join(PLUGIN_ROOT, 'agents'); - -// === Required profile fields (validated by validateProfile) === -export const REQUIRED_FIELDS = ['name', 'description', 'version', 'agents']; -export const REQUIRED_AGENT_KEYS = ['exploration', 'review']; - -// ===================================================================== -// YAML parser (limited subset) -// ===================================================================== - -/** - * Strip a line-trailing comment, respecting quoted strings. - * Returns the line with any '# ...' suffix removed (when '#' is preceded by - * whitespace or starts the line) and trailing whitespace trimmed. - */ -export function stripLineComment(line) { - let inQuote = null; - for (let i = 0; i < line.length; i++) { - const ch = line[i]; - if (inQuote) { - if (ch === '\\') { i++; continue; } - if (ch === inQuote) inQuote = null; - } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === '#') { - const prev = i === 0 ? ' ' : line[i - 1]; - if (prev === ' ' || prev === '\t' || i === 0) { - return line.slice(0, i).replace(/\s+$/, ''); - } - } - } - return line.replace(/\s+$/, ''); -} - -/** - * Pre-process YAML text into an array of {indent, content} entries. - * Strips comments and blank lines. Preserves indent for structural parsing. - */ -function preprocessLines(text) { - const out = []; - for (const raw of text.split('\n')) { - const stripped = stripLineComment(raw); - if (stripped.trim() === '') continue; - const indent = stripped.length - stripped.trimStart().length; - out.push({ indent, content: stripped.trim() }); - } - return out; -} - -/** - * Parse a scalar (right-hand side of `key: value` or list item). - * Recognises quoted strings, bools, null, numbers, inline lists, otherwise - * returns the raw string. - */ -export function parseScalar(text) { - const t = text.trim(); - if (t === '') return null; - if (t === 'null' || t === '~') return null; - if (t === 'true') return true; - if (t === 'false') return false; - if (t.startsWith('[') && t.endsWith(']')) return parseInlineList(t); - if (t.startsWith('"') && t.endsWith('"')) return unescapeString(t.slice(1, -1)); - if (t.startsWith("'") && t.endsWith("'")) return t.slice(1, -1); - // Number? (integer or simple float, no scientific notation needed) - if (/^-?\d+$/.test(t)) return parseInt(t, 10); - if (/^-?\d+\.\d+$/.test(t)) return parseFloat(t); - return t; -} - -function unescapeString(s) { - return s - .replace(/\\n/g, '\n') - .replace(/\\t/g, '\t') - .replace(/\\"/g, '"') - .replace(/\\\\/g, '\\'); -} - -/** - * Parse an inline list like `[a, b, "c d", 1, 2]`. - * Naive split on commas — does not support nested lists/mappings (not needed). - */ -export function parseInlineList(text) { - const inner = text.slice(1, -1).trim(); - if (inner === '') return []; - const items = []; - let depth = 0; - let inQuote = null; - let buf = ''; - for (let i = 0; i < inner.length; i++) { - const ch = inner[i]; - if (inQuote) { - buf += ch; - if (ch === '\\') { buf += inner[++i] ?? ''; continue; } - if (ch === inQuote) inQuote = null; - continue; - } - if (ch === '"' || ch === "'") { inQuote = ch; buf += ch; continue; } - if (ch === '[') depth++; - if (ch === ']') depth--; - if (ch === ',' && depth === 0) { - items.push(parseScalar(buf)); - buf = ''; - continue; - } - buf += ch; - } - if (buf.trim() !== '') items.push(parseScalar(buf)); - return items; -} - -/** - * Parse a block-style list at the given indent. Returns [items, nextIdx]. - * List items are simple scalars only (sufficient for profile schema). - */ -function parseList(lines, idx, indent) { - const items = []; - while (idx < lines.length && lines[idx].indent === indent && lines[idx].content.startsWith('- ')) { - const itemText = lines[idx].content.slice(2).trim(); - items.push(parseScalar(itemText)); - idx++; - } - return [items, idx]; -} - -/** - * Parse a mapping at the given indent. Returns [obj, nextIdx]. - * A mapping key may be followed by a scalar (same line), a nested mapping, - * or a block-list (indented more on subsequent lines). - */ -function parseMapping(lines, idx, indent) { - const result = {}; - while (idx < lines.length && lines[idx].indent === indent) { - const { content } = lines[idx]; - if (content.startsWith('- ')) break; // not a mapping line - const colonIdx = findUnquotedColon(content); - if (colonIdx === -1) { - throw new Error(`Expected 'key: value' at indent ${indent}, got: ${content}`); - } - const key = content.slice(0, colonIdx).trim(); - const rhs = content.slice(colonIdx + 1).trim(); - idx++; - if (rhs === '') { - // Look for nested mapping or list at deeper indent - if (idx < lines.length && lines[idx].indent > indent) { - const childIndent = lines[idx].indent; - if (lines[idx].content.startsWith('- ')) { - const [list, nextIdx] = parseList(lines, idx, childIndent); - result[key] = list; - idx = nextIdx; - } else { - const [obj, nextIdx] = parseMapping(lines, idx, childIndent); - result[key] = obj; - idx = nextIdx; - } - } else { - result[key] = null; - } - } else { - result[key] = parseScalar(rhs); - } - } - return [result, idx]; -} - -function findUnquotedColon(s) { - let inQuote = null; - for (let i = 0; i < s.length; i++) { - const ch = s[i]; - if (inQuote) { - if (ch === '\\') { i++; continue; } - if (ch === inQuote) inQuote = null; - } else if (ch === '"' || ch === "'") { - inQuote = ch; - } else if (ch === ':') { - return i; - } - } - return -1; -} - -/** - * Public: parse a YAML document into a plain JS object. - * Throws on malformed input. - */ -export function parseYaml(text) { - const lines = preprocessLines(text); - if (lines.length === 0) return {}; - const baseIndent = lines[0].indent; - if (lines[0].content.startsWith('- ')) { - const [list] = parseList(lines, 0, baseIndent); - return list; - } - const [obj] = parseMapping(lines, 0, baseIndent); - return obj; -} - -// ===================================================================== -// Profile loader -// ===================================================================== - -/** - * List all built-in profiles by name (filename minus .yaml). - * Sorted alphabetically. - */ -export async function listProfiles({ profilesDir = PROFILES_DIR } = {}) { - let entries; - try { - entries = await readdir(profilesDir); - } catch (err) { - if (err.code === 'ENOENT') return []; - throw err; - } - const names = entries - .filter((f) => f.endsWith('.yaml') || f.endsWith('.yml')) - .map((f) => f.replace(/\.ya?ml$/, '')) - .sort(); - return names; -} - -/** - * Load and validate a profile by name. - * Throws if the file is missing, malformed, or fails validation. - */ -export async function loadProfile(name, opts = {}) { - const profilesDir = opts.profilesDir ?? PROFILES_DIR; - const agentsDir = opts.agentsDir ?? AGENTS_DIR; - const path = await resolveProfilePath(name, profilesDir); - if (!path) { - const available = await listProfiles({ profilesDir }); - throw new Error( - `Profile '${name}' not found in ${profilesDir}. Available: ${available.join(', ') || '(none)'}.` - ); - } - const text = await readFile(path, 'utf8'); - let parsed; - try { - parsed = parseYaml(text); - } catch (err) { - throw new Error(`Failed to parse profile '${name}' (${path}): ${err.message}`); - } - await validateProfile(parsed, { agentsDir, sourcePath: path }); - return parsed; -} - -async function resolveProfilePath(name, profilesDir) { - const candidates = [join(profilesDir, `${name}.yaml`), join(profilesDir, `${name}.yml`)]; - for (const c of candidates) { - try { - const s = await stat(c); - if (s.isFile()) return c; - } catch { - // skip - } - } - return null; -} - -/** - * Validate a parsed profile object against the v1 schema. - * Throws an Error with all validation issues bundled. - */ -export async function validateProfile(profile, opts = {}) { - const agentsDir = opts.agentsDir ?? AGENTS_DIR; - const issues = []; - - if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { - throw new Error('Profile must be a YAML mapping at the top level.'); - } - - for (const field of REQUIRED_FIELDS) { - if (!(field in profile)) issues.push(`Missing required field: ${field}`); - } - - if ('version' in profile && profile.version !== 1) { - issues.push(`Unsupported profile version ${profile.version} (expected 1).`); - } - - if ('agents' in profile) { - const agents = profile.agents; - if (!agents || typeof agents !== 'object' || Array.isArray(agents)) { - issues.push('agents must be a mapping with exploration and review keys.'); - } else { - for (const k of REQUIRED_AGENT_KEYS) { - if (!(k in agents)) issues.push(`agents.${k} is required.`); - else if (!Array.isArray(agents[k])) issues.push(`agents.${k} must be a list of agent names.`); - } - // Cross-check: every agent name must exist in agents/.md - const allAgents = [ - ...(Array.isArray(agents.exploration) ? agents.exploration : []), - ...(Array.isArray(agents.review) ? agents.review : []), - ]; - const missing = await missingAgents(allAgents, agentsDir); - for (const m of missing) issues.push(`Unknown agent referenced: ${m} (no ${agentsDir}/${m}.md).`); - } - } - - if ('axes' in profile && profile.axes !== null) { - if (typeof profile.axes !== 'object' || Array.isArray(profile.axes)) { - issues.push('axes must be a mapping (or omitted).'); - } - } - - if ('triggers' in profile && profile.triggers !== null) { - if (typeof profile.triggers !== 'object' || Array.isArray(profile.triggers)) { - issues.push('triggers must be a mapping (or omitted).'); - } else { - for (const k of ['keywords', 'nfr_signals']) { - if (k in profile.triggers && !Array.isArray(profile.triggers[k])) { - issues.push(`triggers.${k} must be a list.`); - } - } - } - } - - if ('skills' in profile && profile.skills !== null) { - if (typeof profile.skills !== 'object' || Array.isArray(profile.skills)) { - issues.push('skills must be a mapping (or omitted).'); - } else { - for (const k of ['catalog_filter', 'prefer_layer']) { - if (k in profile.skills && !Array.isArray(profile.skills[k])) { - issues.push(`skills.${k} must be a list.`); - } - } - } - } - - if ('adversarial' in profile && profile.adversarial !== null) { - const adv = profile.adversarial; - if (typeof adv !== 'object' || Array.isArray(adv)) { - issues.push('adversarial must be a mapping (or omitted).'); - } else { - if ('depth' in adv && !['light', 'standard', 'deep'].includes(adv.depth)) { - issues.push(`adversarial.depth must be one of light|standard|deep, got: ${adv.depth}`); - } - if ('iterations' in adv && (typeof adv.iterations !== 'number' || adv.iterations < 1)) { - issues.push('adversarial.iterations must be a positive number.'); - } - if ('blockers_only' in adv && typeof adv.blockers_only !== 'boolean') { - issues.push('adversarial.blockers_only must be a boolean.'); - } - } - } - - if (issues.length > 0) { - const src = opts.sourcePath ? ` (${opts.sourcePath})` : ''; - throw new Error(`Profile validation failed${src}:\n - ${issues.join('\n - ')}`); - } - return true; -} - -async function missingAgents(names, agentsDir) { - const missing = []; - for (const n of names) { - if (typeof n !== 'string') { - missing.push(String(n)); - continue; - } - try { - const s = await stat(join(agentsDir, `${n}.md`)); - if (!s.isFile()) missing.push(n); - } catch { - missing.push(n); - } - } - return missing; -} - -// ===================================================================== -// CLI -// ===================================================================== - -const USAGE = `Usage: - node scripts/profile-loader.mjs list - node scripts/profile-loader.mjs load - node scripts/profile-loader.mjs validate -`; - -async function main() { - const args = argv.slice(2); - if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { - stdout.write(USAGE); - return; - } - const cmd = args[0]; - if (cmd === 'list') { - const names = await listProfiles(); - for (const n of names) stdout.write(n + '\n'); - return; - } - if (cmd === 'load') { - const name = args[1]; - if (!name) { stderr.write('load requires \n'); exit(2); } - const profile = await loadProfile(name); - stdout.write(JSON.stringify(profile, null, 2) + '\n'); - return; - } - if (cmd === 'validate') { - const path = args[1]; - if (!path) { stderr.write('validate requires \n'); exit(2); } - const text = await readFile(path, 'utf8'); - const parsed = parseYaml(text); - await validateProfile(parsed, { sourcePath: path }); - stdout.write(`OK: ${basename(path)} validates against profile schema v1\n`); - return; - } - stderr.write(USAGE); - exit(2); -} - -// Run CLI only if invoked directly, not when imported. -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((err) => { - stderr.write(`Error: ${err.message}\n`); - exit(1); - }); -} diff --git a/plugins/ultraplan-local/scripts/profile-loader.test.mjs b/plugins/ultraplan-local/scripts/profile-loader.test.mjs deleted file mode 100644 index 20743a6..0000000 --- a/plugins/ultraplan-local/scripts/profile-loader.test.mjs +++ /dev/null @@ -1,379 +0,0 @@ -// node:test suite for scripts/profile-loader.mjs -// -// Run: node --test scripts/profile-loader.test.mjs -// -// Covers: YAML parser subset, profile validation, agent cross-checks, -// listProfiles, loadProfile happy path, error paths. - -import { test } from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { - parseYaml, - parseScalar, - parseInlineList, - stripLineComment, - validateProfile, - loadProfile, - listProfiles, -} from './profile-loader.mjs'; - -// ===================================================================== -// YAML parser tests -// ===================================================================== - -test('parseScalar: numbers, bools, null, strings', () => { - assert.equal(parseScalar('42'), 42); - assert.equal(parseScalar('-7'), -7); - assert.equal(parseScalar('3.14'), 3.14); - assert.equal(parseScalar('true'), true); - assert.equal(parseScalar('false'), false); - assert.equal(parseScalar('null'), null); - assert.equal(parseScalar('~'), null); - assert.equal(parseScalar('hello'), 'hello'); - assert.equal(parseScalar('"quoted with spaces"'), 'quoted with spaces'); - assert.equal(parseScalar("'single quoted'"), 'single quoted'); -}); - -test('parseScalar: empty string returns null', () => { - assert.equal(parseScalar(''), null); - assert.equal(parseScalar(' '), null); -}); - -test('parseInlineList: basic', () => { - assert.deepEqual(parseInlineList('[]'), []); - assert.deepEqual(parseInlineList('[a, b, c]'), ['a', 'b', 'c']); - assert.deepEqual(parseInlineList('[1, 2, 3]'), [1, 2, 3]); - assert.deepEqual(parseInlineList('["x y", "z"]'), ['x y', 'z']); -}); - -test('parseInlineList: commas inside quoted strings are preserved', () => { - assert.deepEqual(parseInlineList('["a, b", "c"]'), ['a, b', 'c']); -}); - -test('stripLineComment: line-leading hash', () => { - assert.equal(stripLineComment('# comment'), ''); - assert.equal(stripLineComment(' # indented'), ''); -}); - -test('stripLineComment: trailing comment after value', () => { - assert.equal(stripLineComment('key: value # explanation'), 'key: value'); - assert.equal(stripLineComment('key: 42 # number'), 'key: 42'); -}); - -test('stripLineComment: hash inside quoted string is preserved', () => { - assert.equal(stripLineComment('key: "value # not a comment"'), 'key: "value # not a comment"'); -}); - -test('parseYaml: simple flat mapping', () => { - const result = parseYaml('name: foo\nversion: 1\nactive: true'); - assert.deepEqual(result, { name: 'foo', version: 1, active: true }); -}); - -test('parseYaml: nested mapping', () => { - const text = ` -axes: - depth: deep - domain: security -`; - const result = parseYaml(text); - assert.deepEqual(result, { axes: { depth: 'deep', domain: 'security' } }); -}); - -test('parseYaml: block-style list', () => { - const text = ` -agents: - - architecture-mapper - - risk-assessor - - task-finder -`; - const result = parseYaml(text); - assert.deepEqual(result, { agents: ['architecture-mapper', 'risk-assessor', 'task-finder'] }); -}); - -test('parseYaml: inline list as scalar value', () => { - const text = 'keywords: [a, b, c]'; - const result = parseYaml(text); - assert.deepEqual(result, { keywords: ['a', 'b', 'c'] }); -}); - -test('parseYaml: full profile-shaped structure', () => { - const text = ` -name: test -description: "A test profile" -version: 1 -axes: - depth: standard - domain: general -triggers: - keywords: ["foo", "bar"] - nfr_signals: [] -agents: - exploration: - - architecture-mapper - - task-finder - review: - - plan-critic -adversarial: - depth: deep - iterations: 2 - blockers_only: false -`; - const result = parseYaml(text); - assert.equal(result.name, 'test'); - assert.equal(result.description, 'A test profile'); - assert.equal(result.version, 1); - assert.deepEqual(result.axes, { depth: 'standard', domain: 'general' }); - assert.deepEqual(result.triggers.keywords, ['foo', 'bar']); - assert.deepEqual(result.triggers.nfr_signals, []); - assert.deepEqual(result.agents.exploration, ['architecture-mapper', 'task-finder']); - assert.deepEqual(result.agents.review, ['plan-critic']); - assert.equal(result.adversarial.depth, 'deep'); - assert.equal(result.adversarial.iterations, 2); - assert.equal(result.adversarial.blockers_only, false); -}); - -test('parseYaml: ignores blank lines and comments', () => { - const text = ` -# Header comment - -name: foo - -# Mid comment -version: 1 -`; - const result = parseYaml(text); - assert.deepEqual(result, { name: 'foo', version: 1 }); -}); - -test('parseYaml: throws on missing colon', () => { - assert.throws(() => parseYaml('name foo'), /Expected 'key: value'/); -}); - -// ===================================================================== -// Helpers: build a minimal plugin tree under tmpdir -// ===================================================================== - -async function makeTempPluginTree(profiles, agentNames) { - const root = await mkdtemp(join(tmpdir(), 'profile-loader-test-')); - const profilesDir = join(root, 'profiles'); - const agentsDir = join(root, 'agents'); - await mkdir(profilesDir, { recursive: true }); - await mkdir(agentsDir, { recursive: true }); - for (const [name, content] of Object.entries(profiles)) { - await writeFile(join(profilesDir, `${name}.yaml`), content, 'utf8'); - } - for (const a of agentNames) { - await writeFile(join(agentsDir, `${a}.md`), '# stub', 'utf8'); - } - return { root, profilesDir, agentsDir }; -} - -// ===================================================================== -// validateProfile tests -// ===================================================================== - -test('validateProfile: passes on minimal valid profile', async () => { - const { profilesDir, agentsDir, root } = await makeTempPluginTree( - {}, - ['agent-a', 'agent-b', 'agent-c'] - ); - try { - const profile = { - name: 'minimal', - description: 'Minimal valid profile', - version: 1, - agents: { - exploration: ['agent-a', 'agent-b'], - review: ['agent-c'], - }, - }; - await validateProfile(profile, { agentsDir }); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('validateProfile: rejects missing required fields', async () => { - const { agentsDir, root } = await makeTempPluginTree({}, []); - try { - await assert.rejects( - validateProfile({ name: 'x' }, { agentsDir }), - /Missing required field: description/ - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('validateProfile: rejects unknown agent name', async () => { - const { agentsDir, root } = await makeTempPluginTree({}, ['real-agent']); - try { - const profile = { - name: 'bad', - description: 'has ghost agent', - version: 1, - agents: { - exploration: ['real-agent', 'ghost-agent'], - review: ['real-agent'], - }, - }; - await assert.rejects( - validateProfile(profile, { agentsDir }), - /Unknown agent referenced: ghost-agent/ - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('validateProfile: rejects unsupported version', async () => { - const { agentsDir, root } = await makeTempPluginTree({}, ['a']); - try { - const profile = { - name: 'x', - description: 'old', - version: 99, - agents: { exploration: ['a'], review: ['a'] }, - }; - await assert.rejects( - validateProfile(profile, { agentsDir }), - /Unsupported profile version 99/ - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('validateProfile: rejects adversarial.depth not in enum', async () => { - const { agentsDir, root } = await makeTempPluginTree({}, ['a']); - try { - const profile = { - name: 'x', - description: 'bad depth', - version: 1, - agents: { exploration: ['a'], review: ['a'] }, - adversarial: { depth: 'extreme', iterations: 1, blockers_only: false }, - }; - await assert.rejects( - validateProfile(profile, { agentsDir }), - /adversarial\.depth must be one of/ - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('validateProfile: rejects when agents key is array not mapping', async () => { - const { agentsDir, root } = await makeTempPluginTree({}, []); - try { - const profile = { - name: 'x', - description: 'wrong shape', - version: 1, - agents: ['a', 'b'], - }; - await assert.rejects( - validateProfile(profile, { agentsDir }), - /agents must be a mapping/ - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -// ===================================================================== -// listProfiles + loadProfile -// ===================================================================== - -test('listProfiles: returns sorted basenames without extension', async () => { - const { profilesDir, root } = await makeTempPluginTree( - { - 'zebra': 'name: z\ndescription: z\nversion: 1\nagents:\n exploration: []\n review: []', - 'alpha': 'name: a\ndescription: a\nversion: 1\nagents:\n exploration: []\n review: []', - 'middle': 'name: m\ndescription: m\nversion: 1\nagents:\n exploration: []\n review: []', - }, - [] - ); - try { - const names = await listProfiles({ profilesDir }); - assert.deepEqual(names, ['alpha', 'middle', 'zebra']); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('listProfiles: empty when directory missing', async () => { - const names = await listProfiles({ profilesDir: '/tmp/does-not-exist-xyz' }); - assert.deepEqual(names, []); -}); - -test('loadProfile: parses + validates an actual file', async () => { - const yaml = ` -name: t -description: "Test" -version: 1 -axes: - depth: standard - domain: general -agents: - exploration: - - agent-a - review: - - agent-b -`; - const { profilesDir, agentsDir, root } = await makeTempPluginTree( - { 't': yaml }, - ['agent-a', 'agent-b'] - ); - try { - const p = await loadProfile('t', { profilesDir, agentsDir }); - assert.equal(p.name, 't'); - assert.deepEqual(p.agents.exploration, ['agent-a']); - assert.deepEqual(p.agents.review, ['agent-b']); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -test('loadProfile: throws helpful message when name missing', async () => { - const { profilesDir, agentsDir, root } = await makeTempPluginTree( - { - 'real': 'name: r\ndescription: r\nversion: 1\nagents:\n exploration: []\n review: []', - }, - [] - ); - try { - await assert.rejects( - loadProfile('not-real', { profilesDir, agentsDir }), - /Profile 'not-real' not found.*Available: real/ - ); - } finally { - await rm(root, { recursive: true, force: true }); - } -}); - -// ===================================================================== -// Integration: built-in default.yaml -// ===================================================================== - -test('built-in default.yaml: parses and validates', async () => { - // No path overrides — use the actual plugin profiles/ + agents/ dirs. - const profile = await loadProfile('default'); - assert.equal(profile.name, 'default'); - assert.equal(profile.version, 1); - assert.ok(Array.isArray(profile.agents.exploration)); - assert.ok(Array.isArray(profile.agents.review)); - // Specific agents should be the current Phase 5/9 set. - assert.ok(profile.agents.exploration.includes('architecture-mapper')); - assert.ok(profile.agents.exploration.includes('task-finder')); - assert.ok(profile.agents.review.includes('plan-critic')); - assert.ok(profile.agents.review.includes('scope-guardian')); -}); - -test('listProfiles: includes default', async () => { - const names = await listProfiles(); - assert.ok(names.includes('default'), `Expected default in ${names.join(', ')}`); -});