feat(ultraplan-local): M0 — profile foundation, no behaviour change
Introduces a profile-loader infrastructure for runtime-instantiable ultraplan variants (depth × domain × goal axes). M0 ships only the `default` profile, which mirrors the current hardcoded Phase 5/9 agent set — so existing flows are unaffected. What lands: - profiles/default.yaml — schema v1, lists current 8 exploration agents + 2 review agents, captures today's adversarial regime - scripts/profile-loader.mjs — null-deps Node loader with limited-subset YAML parser, listProfiles(), loadProfile(), validateProfile() that cross-checks every referenced agent exists in agents/ - scripts/profile-loader.test.mjs — 26 node:test cases (parser, validation, loader, integration with built-in default.yaml) - commands/ultraplan-local.md — Phase 1 gains a "Resolve the profile" step (--profile flag → brief.recommended_profile → default fallback) and prints profile + source in the mode report. Phase 5/9 unchanged. - README.md, CLAUDE.md, marketplace README — documentation of the M0 foundation, the universal-brief design principle, and the M1/M2/M3 milestones to come. M1 (next) wires profile recommendation into ultrabrief Phase 4. M2 ships the 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. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
3b57dfbf6d
commit
0b28f008ae
7 changed files with 989 additions and 1 deletions
|
|
@ -81,6 +81,14 @@ 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 <dir>` works across `/ultraresearch-local`, `/ultra-cc-architect-local`, `/ultraplan-local`, and `/ultraexecute-local`.
|
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 <dir>` 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/<name>.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.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 (`<cc_feature>[-<qualifier>]-<layer>.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}/<slug>.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.
|
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 (`<cc_feature>[-<qualifier>]-<layer>.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}/<slug>.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.
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,19 @@ 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.
|
**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 <dir>` fills in `{dir}/research/`. `/ultra-cc-architect-local --project <dir>` *(optional, v2.2)* matches available Claude Code features against brief+research and writes `{dir}/architecture/`. `/ultraplan-local --project <dir>` reads brief + research (+ architecture note if present) to produce `{dir}/plan.md`. `/ultraexecute-local --project <dir>` executes and writes `{dir}/progress.json`. All artifacts live in one project directory.
|
**Pipeline:** `/ultrabrief-local` produces the task brief. `/ultraresearch-local --project <dir>` fills in `{dir}/research/`. `/ultra-cc-architect-local --project <dir>` *(optional, v2.2)* matches available Claude Code features against brief+research and writes `{dir}/architecture/`. `/ultraplan-local --project <dir>` reads brief + research (+ architecture note if present) to produce `{dir}/plan.md`. `/ultraexecute-local --project <dir>` 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/`.
|
**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/`.
|
||||||
|
|
|
||||||
|
|
@ -266,11 +266,39 @@ Output:
|
||||||
| **Research-enriched** | `/ultraplan-local --project <dir> --research <brief>` | Add extra research briefs beyond what is in `research/` |
|
| **Research-enriched** | `/ultraplan-local --project <dir> --research <brief>` | Add extra research briefs beyond what is in `research/` |
|
||||||
| **Foreground** | `/ultraplan-local --project <dir> --fg` | No-op alias (foreground is default since v2.4.0) |
|
| **Foreground** | `/ultraplan-local --project <dir> --fg` | No-op alias (foreground is default since v2.4.0) |
|
||||||
| **Quick** | `/ultraplan-local --project <dir> --quick` | No agent swarm, lightweight scan only |
|
| **Quick** | `/ultraplan-local --project <dir> --quick` | No agent swarm, lightweight scan only |
|
||||||
|
| **Profile** | `/ultraplan-local --project <dir> --profile <name>` | Explicit profile override (overrides `recommended_profile` in brief) |
|
||||||
| **Decompose** | `/ultraplan-local --decompose plan.md` | Split plan into headless session specs |
|
| **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 |
|
| **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`.
|
`--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
|
### What the plan contains
|
||||||
|
|
||||||
Every plan includes:
|
Every plan includes:
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,12 @@ Parse `$ARGUMENTS` for mode flags. Order of precedence:
|
||||||
7. **`--quick`** — set **mode = quick**. Skip agent swarm; use lightweight
|
7. **`--quick`** — set **mode = quick**. Skip agent swarm; use lightweight
|
||||||
Glob/Grep scan and go directly to planning + adversarial review.
|
Glob/Grep scan and go directly to planning + adversarial review.
|
||||||
|
|
||||||
8. If neither `--brief` nor `--project` is present after flag parsing,
|
8. **`--profile <name>`** — 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,
|
||||||
output usage and stop:
|
output usage and stop:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
@ -100,6 +105,7 @@ Usage: /ultraplan-local --brief <path-to-brief.md>
|
||||||
/ultraplan-local --brief <path> --research <research-brief.md>
|
/ultraplan-local --brief <path> --research <research-brief.md>
|
||||||
/ultraplan-local --project <dir> --fg
|
/ultraplan-local --project <dir> --fg
|
||||||
/ultraplan-local --project <dir> --quick
|
/ultraplan-local --project <dir> --quick
|
||||||
|
/ultraplan-local --project <dir> --profile <name>
|
||||||
/ultraplan-local --export <pr|issue|markdown|headless> <plan-path>
|
/ultraplan-local --export <pr|issue|markdown|headless> <plan-path>
|
||||||
/ultraplan-local --decompose <plan-path>
|
/ultraplan-local --decompose <plan-path>
|
||||||
|
|
||||||
|
|
@ -111,6 +117,8 @@ Modes:
|
||||||
--research Add up to 3 extra research briefs as planning context
|
--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)
|
--fg No-op alias (foreground is the only mode as of v2.4.0)
|
||||||
--quick Skip exploration agent swarm; plan directly
|
--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)
|
--export Generate shareable output from an existing plan (no new planning)
|
||||||
--decompose Split an existing plan into self-contained headless sessions
|
--decompose Split an existing plan into self-contained headless sessions
|
||||||
|
|
||||||
|
|
@ -119,6 +127,7 @@ Examples:
|
||||||
/ultraplan-local --brief .claude/projects/2026-04-18-jwt-auth/brief.md
|
/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 --research extra.md
|
||||||
/ultraplan-local --project .claude/projects/2026-04-18-jwt-auth --fg
|
/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 --export pr .claude/plans/ultraplan-2026-04-06-rate-limiting.md
|
||||||
/ultraplan-local --decompose .claude/plans/ultraplan-2026-04-06-rate-limiting.md
|
/ultraplan-local --decompose .claude/plans/ultraplan-2026-04-06-rate-limiting.md
|
||||||
|
|
||||||
|
|
@ -144,12 +153,52 @@ If `research_status == pending` and `research_topics > 0`:
|
||||||
- If cancel: print the research invocations from the brief's "How to continue"
|
- If cancel: print the research invocations from the brief's "How to continue"
|
||||||
section and stop.
|
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 <name>` 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:
|
Report the detected mode:
|
||||||
```
|
```
|
||||||
Mode: {foreground | quick | export | decompose}
|
Mode: {foreground | quick | export | decompose}
|
||||||
Brief: {brief_path}
|
Brief: {brief_path}
|
||||||
Project: {project_dir or "-"}
|
Project: {project_dir or "-"}
|
||||||
Research: {N local briefs, M extra via --research}
|
Research: {N local briefs, M extra via --research}
|
||||||
|
Profile: {profile.name} (source: {profile_source})
|
||||||
```
|
```
|
||||||
|
|
||||||
## Phase 1.5 — Export (runs only when mode = export)
|
## Phase 1.5 — Export (runs only when mode = export)
|
||||||
|
|
|
||||||
60
plugins/ultraplan-local/profiles/default.yaml
Normal file
60
plugins/ultraplan-local/profiles/default.yaml
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# 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
|
||||||
451
plugins/ultraplan-local/scripts/profile-loader.mjs
Normal file
451
plugins/ultraplan-local/scripts/profile-loader.mjs
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
#!/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 <name>
|
||||||
|
|
||||||
|
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/<name>.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 <name>
|
||||||
|
node scripts/profile-loader.mjs validate <path-to-yaml>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 <name>\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 <path>\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);
|
||||||
|
});
|
||||||
|
}
|
||||||
379
plugins/ultraplan-local/scripts/profile-loader.test.mjs
Normal file
379
plugins/ultraplan-local/scripts/profile-loader.test.mjs
Normal file
|
|
@ -0,0 +1,379 @@
|
||||||
|
// 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(', ')}`);
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue