convoke-agents
Advanced tools
| name: artifacts | ||
| version: 1.0.0 | ||
| description: "Artifacts module — operator-facing skills for the Convoke artifact system (migration, portfolio status, future Experience Contract validation)" | ||
| # Workflows in this module are STANDALONE (operator invokes them directly via slash command), | ||
| # unlike _enhance workflows which patch into a target_agent's menu. The `standalone: true` | ||
| # flag is consumed by refresh-installation.js (Story 6.6) to skip menu-patching and only | ||
| # generate a Claude Code skill wrapper. Without this flag, refresh-installation would treat | ||
| # the workflow as a menu-patch workflow (the Enhance default) and fail to find a target_agent. | ||
| workflows: | ||
| - name: bmad-migrate-artifacts | ||
| entry: workflows/bmad-migrate-artifacts/workflow.md | ||
| standalone: true | ||
| - name: bmad-portfolio-status | ||
| entry: workflows/bmad-portfolio-status/workflow.md | ||
| standalone: true |
| --- | ||
| name: bmad-migrate-artifacts | ||
| description: Migrate artifact governance metadata to conform to taxonomy. Use when the user says "run artifact migration" or "migrate artifacts". | ||
| --- | ||
| IT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL {project-root}/_bmad/bme/_artifacts/workflows/bmad-migrate-artifacts/workflow.md, READ its entire contents and follow its directions exactly! |
| # Step 1: Scope Selection | ||
| ## STEP GOAL: | ||
| To explain to the operator what artifact migration does, present the default scan scope, and confirm or adjust which directories the migration should cover. | ||
| ## MANDATORY EXECUTION RULES (READ FIRST): | ||
| ### Universal Rules: | ||
| - 🛑 NEVER execute the migration without explicit user confirmation | ||
| - 📖 CRITICAL: Read the complete step file before taking any action | ||
| - 🔄 CRITICAL: When loading next step with 'C', ensure entire file is read | ||
| - 📋 YOU ARE A FACILITATOR, not a script runner | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### Role Reinforcement: | ||
| - ✅ You are a guided-migration assistant — your job is to make a CLI tool feel like a conversation | ||
| - ✅ You explain what is about to happen in plain language, then defer to the operator's decisions | ||
| - ✅ The CLI does the work; you orchestrate the conversation around it | ||
| ### Step-Specific Rules: | ||
| - 🎯 Focus ONLY on scope selection in this step | ||
| - 🚫 FORBIDDEN to invoke the migration CLI in this step (Step 2 does that) | ||
| - 🚫 FORBIDDEN to ask about ambiguous file resolutions yet (Step 3 does that) | ||
| - 💬 Capture the operator's chosen scope as `{{scope}}` for downstream steps | ||
| ## EXECUTION PROTOCOLS: | ||
| - 🎯 Start with a 2-sentence explanation of what artifact migration does | ||
| - 💾 Hold scope selection in working memory (no file persisted in this step) | ||
| - 🚫 FORBIDDEN to load next step until the operator selects 'C' AND scope is confirmed | ||
| ## CONTEXT BOUNDARIES: | ||
| - Available context: project root, default scan directories | ||
| - Focus: presenting scope, capturing operator confirmation | ||
| - Limits: no CLI invocation, no manifest generation | ||
| - Dependencies: none (this is the first step) | ||
| ## Sequence of Instructions (Do not deviate, skip, or optimize) | ||
| ### 1. Welcome and brief explanation | ||
| Greet `{user_name}` and explain in two sentences: | ||
| > "Artifact migration scans your `_bmad-output/` directories, infers the right initiative + type for each file, and renames everything to follow the governance convention `{initiative}-{type}-{qualifier}-{date}.md`. It runs in dry-run mode first so you can review every proposed change before anything is touched." | ||
| ### 2. Count files in default scope directories | ||
| Run these shell commands and capture the counts: | ||
| ``` | ||
| ls _bmad-output/planning-artifacts | wc -l | ||
| ls _bmad-output/vortex-artifacts | wc -l | ||
| ls _bmad-output/gyre-artifacts | wc -l | ||
| ``` | ||
| If any directory does not exist, report `0` for it (do not error out). | ||
| ### 3. Present the default scope | ||
| Present: | ||
| > **Default scan scope:** | ||
| > | ||
| > - `planning-artifacts` ({N1} files) | ||
| > - `vortex-artifacts` ({N2} files) | ||
| > - `gyre-artifacts` ({N3} files) | ||
| > | ||
| > **Total: {N1+N2+N3} files** | ||
| ### 4. Ask for confirmation or override | ||
| Display this menu and HALT for input: | ||
| ``` | ||
| [C] Continue with default scope | ||
| [O] Override — specify a custom comma-separated list of directory names | ||
| [X] Exit — abort the migration entirely | ||
| ``` | ||
| ### 5. Handle the operator's response | ||
| **IF the operator chose `[C]`:** | ||
| - Set `{{scope}}` = `planning-artifacts,vortex-artifacts,gyre-artifacts` | ||
| - Proceed to step 6 below. | ||
| **IF the operator chose `[O]`:** | ||
| - Ask: `"Specify a comma-separated list of directory names (relative to _bmad-output/):"` | ||
| - HALT for input. | ||
| - When the operator responds, validate the input: | ||
| - Must be comma-separated | ||
| - Each name must match `^[a-zA-Z0-9_-]+$` (no path traversal, no spaces, no special chars) | ||
| - At least one name must exist as a directory under `_bmad-output/` | ||
| - If validation fails, explain WHY (which name is invalid or missing) and re-ask. Loop until valid or until the operator chooses to abort. | ||
| - Set `{{scope}}` = the validated comma-separated list. | ||
| - Proceed to step 6 below. | ||
| **IF the operator chose `[X]`:** | ||
| - Reply: `"Migration aborted. No changes made."` | ||
| - HALT permanently. Do NOT load step 2. | ||
| ### 6. Confirm and transition | ||
| Show: | ||
| > ✅ **Scope confirmed:** `{{scope}}` | ||
| > | ||
| > Ready to generate the dry-run manifest. Type `C` to continue. | ||
| HALT for input. When the operator types `C`, read fully and follow `./step-02-dryrun.md`. | ||
| ## CRITICAL STEP COMPLETION NOTE | ||
| ONLY when the operator has confirmed scope and typed `C` will you read fully and follow `./step-02-dryrun.md`. Do NOT auto-proceed. | ||
| --- | ||
| ## 🚨 SYSTEM SUCCESS/FAILURE METRICS | ||
| ### ✅ SUCCESS: | ||
| - Operator understands what migration does | ||
| - File counts shown for default scope | ||
| - `{{scope}}` captured (default or custom) and validated | ||
| - Operator typed `C` to proceed | ||
| ### ❌ SYSTEM FAILURE: | ||
| - Invoking the migration CLI in this step | ||
| - Auto-proceeding without `C` | ||
| - Accepting an unvalidated custom scope | ||
| - Skipping the file count display | ||
| **Master Rule:** Skipping steps, optimizing sequences, or not following exact instructions is FORBIDDEN and constitutes SYSTEM FAILURE. |
| # Step 2: Dry-Run Review | ||
| ## STEP GOAL: | ||
| To run the migration in dry-run mode, parse the manifest output into 5 categories, and present them to the operator in a structured format (instead of dumping the raw CLI wall of text). | ||
| ## MANDATORY EXECUTION RULES (READ FIRST): | ||
| ### Universal Rules: | ||
| - 🛑 NEVER skip the dry-run — every migration MUST go through this step | ||
| - 📖 CRITICAL: Read the complete step file before taking any action | ||
| - 🔄 CRITICAL: When loading next step with 'C', ensure entire file is read | ||
| - 📋 YOU ARE A FACILITATOR presenting categorized results, not a content generator | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### Role Reinforcement: | ||
| - ✅ You parse the CLI's output into buckets and present them clearly | ||
| - ✅ The operator should never see a raw 70-line wall of text — group by category | ||
| ### Step-Specific Rules: | ||
| - 🎯 Focus ONLY on dry-run + categorization in this step | ||
| - 🚫 FORBIDDEN to invoke `--apply` (Step 4 does that) | ||
| - 🚫 FORBIDDEN to ask for resolutions yet (Step 3 does that) | ||
| - 💬 Hold the parsed bucket data in working memory for Step 3 to consume | ||
| ## EXECUTION PROTOCOLS: | ||
| - 🎯 Shell out to the CLI in dry-run mode | ||
| - 💾 Parse output into 7 buckets via simple substring matching (NOT regex) | ||
| - 🚫 FORBIDDEN to load next step until the operator selects 'C' or 'X' | ||
| ## CONTEXT BOUNDARIES: | ||
| - Available context: `{{scope}}` from Step 1 | ||
| - Focus: dry-run execution + output parsing + bucket presentation | ||
| - Limits: no resolution prompts, no apply | ||
| - Dependencies: Step 1 must have set `{{scope}}` | ||
| ## Sequence of Instructions (Do not deviate, skip, or optimize) | ||
| ### 1. Run the migration in dry-run mode | ||
| Shell out to: | ||
| ``` | ||
| node scripts/migrate-artifacts.js --include {{scope}} | ||
| ``` | ||
| Capture stdout in full. **Note:** the CLI returns immediately after printing the manifest in dry-run mode (no `--apply`) — it does NOT enter the interactive ambiguity prompt. That only fires in apply mode. So this shell-out cannot hang on operator input. The CLI will output a manifest in this format (defined in `scripts/lib/artifact-utils.js` `formatManifest()`): | ||
| ``` | ||
| [SKIP] dir/file.md -- already governed | ||
| [INJECT] dir/file.md -- frontmatter needed | ||
| dir/old.md -> dir/new.md | ||
| Initiative: name (confidence: high, source: inferred) | ||
| Type: name (confidence: high, source: prefix) | ||
| [!] COLLISION: same target as dir/other.md | ||
| Suggested rename: dir/new-with-suffix.md | ||
| [!] dir/file.md -> CONFLICT (filename says X, frontmatter says Y) | ||
| ACTION REQUIRED: Resolve initiative conflict before migration | ||
| [!] dir/file.md -> ??? (ambiguous -- type: prd, initiative unknown) | ||
| Line 1: "..." | ||
| Line 2: "..." | ||
| Line 3: "..." | ||
| Git author: Name (date) | ||
| Suggested: convoke (source: folder-default, confidence: low) | ||
| REVIEW SUGGESTION: Accept 'convoke' or specify initiative | ||
| [!] dir/file.md -> ??? (ambiguous -- type: prd, initiative unknown) | ||
| Line 1: "..." | ||
| ACTION REQUIRED: Specify initiative for this file | ||
| --- Manifest Summary --- | ||
| Total: 73 | Rename: 42 | Skip: 0 | Inject: 0 | Conflict: 0 | Ambiguous: 31 | ||
| [!] 1 filename collision(s) detected -- resolve before executing | ||
| ``` | ||
| ### 2. Parse the output into 7 buckets | ||
| Use **simple substring matching**, not regex. Walk the output line by line and bucket each entry by its action label: | ||
| | Bucket | Detection rule | | ||
| |--------|---------------| | ||
| | **SKIP** | Line starts with `[SKIP] ` | | ||
| | **INJECT_ONLY** | Line starts with `[INJECT] ` | | ||
| | **CLEAN RENAME** | Line contains ` -> ` AND does NOT start with `[!] ` (i.e., the rename arrow appears, the line is not flagged ambiguous/conflict) AND no `[!] COLLISION` appears in the next 2–4 lines | | ||
| | **COLLISION** | A line matching the CLEAN RENAME shape but with `[!] COLLISION:` in one of its 2–4 follow-up lines (categorized as COLLISION instead of CLEAN RENAME) | | ||
| | **CONFLICT** | Line starts with `[!] ` and contains `-> CONFLICT (` | | ||
| | **REVIEW SUGGESTION** | Line starts with `[!] ` and contains `??? (ambiguous` AND a subsequent `Suggested:` line exists before the next entry | | ||
| | **PURE AMBIGUOUS** | Line starts with `[!] ` and contains `??? (ambiguous` AND NO `Suggested:` line follows before the next entry | | ||
| For each entry, store in working memory: | ||
| - `oldPath` (the full `dir/filename.md`) | ||
| - `category` (one of the 7 above) | ||
| - For RENAMEs: `newPath`, `initiative`, `type` | ||
| - For COLLISIONs: `suggestedNewPath`, `collisionWith` | ||
| - For REVIEW SUGGESTIONs: `suggestedInitiative`, `suggestedFrom`, `suggestedConfidence`, `firstLines` (the 3 context lines), `gitAuthor` | ||
| - For PURE AMBIGUOUS: `firstLines`, `gitAuthor`, `candidates` | ||
| This bucket data structure becomes `{{buckets}}` for Step 3. | ||
| ### 3. Present the categorized results | ||
| Display in this order, with clear visual separation: | ||
| > ### 📊 Dry-Run Summary | ||
| > | ||
| > **Total entries:** {total} | ||
| > | ||
| > --- | ||
| > | ||
| > ✅ **{N} clean renames** (no operator action needed) | ||
| > | ||
| > First 5 examples: | ||
| > - `{old1}` → `{new1}` | ||
| > - `{old2}` → `{new2}` | ||
| > - ... (truncate after 5; show "_+ {N-5} more_" if there are more) | ||
| > | ||
| > --- | ||
| > | ||
| > 💡 **{N} review suggestions** (the engine has a default — you can accept or override) | ||
| > | ||
| > Each entry on its own line: | ||
| > - `{old}` → suggested `{initiative}` (`{source}`, confidence `{confidence}`) | ||
| > - ... | ||
| > | ||
| > --- | ||
| > | ||
| > ❗ **{N} pure ambiguous** (no suggestion — operator must specify) | ||
| > | ||
| > Each entry with first content line: | ||
| > - `{old}` — _line 1: "{first_line}"_ | ||
| > - ... | ||
| > | ||
| > --- | ||
| > | ||
| > ⚠️ **{N} collisions** (auto-suggested differentiator if available) | ||
| > | ||
| > Each entry on its own line: | ||
| > - `{old}` → collides with `{other}`. Suggested: `{suggestedNewPath}` | ||
| > | ||
| > --- | ||
| > | ||
| > 🚨 **{N} conflicts** (filename ↔ frontmatter mismatch — must resolve manually before migration) | ||
| > | ||
| > Each entry with the conflict description. | ||
| > | ||
| > --- | ||
| > | ||
| > 📁 **{N} skip** / 💉 **{N} inject-only** (no action needed) | ||
| If a bucket is empty, omit its section entirely (don't show "0 collisions"). | ||
| ### 4. Present continuation menu | ||
| Display this menu and HALT for input: | ||
| ``` | ||
| [C] Continue to resolution (Step 3) | ||
| [X] Exit — abort the migration entirely | ||
| ``` | ||
| ### 5. Handle the operator's response | ||
| **IF the operator chose `[C]`:** | ||
| - If both `REVIEW SUGGESTION` and `PURE AMBIGUOUS` buckets are EMPTY: skip Step 3 entirely and go directly to Step 4 (nothing to resolve). | ||
| - Otherwise: read fully and follow `./step-03-resolve.md`. | ||
| **IF the operator chose `[X]`:** | ||
| - Reply: `"Migration aborted at dry-run stage. No changes made."` | ||
| - HALT permanently. | ||
| ## CRITICAL STEP COMPLETION NOTE | ||
| ONLY when the operator has typed `C` and the bucket data is captured will you read fully and follow the next step. Do NOT auto-proceed. | ||
| --- | ||
| ## 🚨 SYSTEM SUCCESS/FAILURE METRICS | ||
| ### ✅ SUCCESS: | ||
| - Dry-run CLI invoked with `{{scope}}` | ||
| - All entries parsed into the correct bucket | ||
| - Categories presented in the correct visual order | ||
| - Bucket data held in working memory for Step 3 | ||
| - Operator typed `C` or `X` | ||
| ### ❌ SYSTEM FAILURE: | ||
| - Dumping the raw CLI output without parsing | ||
| - Skipping the bucket presentation | ||
| - Auto-proceeding without `C` | ||
| - Invoking `--apply` in this step | ||
| - Asking resolution questions in this step | ||
| **Master Rule:** Skipping steps, optimizing sequences, or not following exact instructions is FORBIDDEN and constitutes SYSTEM FAILURE. |
| # Step 3: Interactive Resolution | ||
| ## STEP GOAL: | ||
| To walk the operator through the REVIEW SUGGESTION and PURE AMBIGUOUS entries from Step 2 conversationally, capturing each decision into the `{{resolutions}}` map that Step 4 will write to the resolution-file JSON. | ||
| ## MANDATORY EXECUTION RULES (READ FIRST): | ||
| ### Universal Rules: | ||
| - 🛑 NEVER skip an entry without explicit operator input (or batch shortcut) | ||
| - 📖 CRITICAL: Read the complete step file before taking any action | ||
| - 🔄 CRITICAL: When loading next step with 'C', ensure entire file is read | ||
| - 📋 YOU ARE A FACILITATOR — present each decision, capture the answer, move on | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### Role Reinforcement: | ||
| - ✅ You are walking the operator through one decision at a time | ||
| - ✅ Operator decisions are AUTHORITATIVE — never override or "fix" them | ||
| - ✅ Use the batch shortcut (`all <initiative>`) to keep the loop manageable | ||
| ### Step-Specific Rules: | ||
| - 🎯 Focus ONLY on resolution capture in this step | ||
| - 🚫 FORBIDDEN to invoke the migration CLI in this step (Step 4 does that) | ||
| - 🚫 FORBIDDEN to skip an entry without operator input | ||
| - 💬 Operator overrides MUST be honored — never silently dropped | ||
| ## EXECUTION PROTOCOLS: | ||
| - 🎯 Iterate REVIEW SUGGESTION first, then PURE AMBIGUOUS | ||
| - 💾 Build `{{resolutions}}` map keyed by `oldPath` with `{ action, initiative? }` values | ||
| - 🚫 FORBIDDEN to load next step until the operator selects 'C' or all entries are resolved/skipped | ||
| ## CONTEXT BOUNDARIES: | ||
| - Available context: `{{scope}}` from Step 1, `{{buckets}}` from Step 2 | ||
| - Focus: capturing operator decisions for ambiguous entries | ||
| - Limits: no CLI invocation, no apply | ||
| - Dependencies: Step 2 must have parsed entries into `REVIEW SUGGESTION` and `PURE AMBIGUOUS` buckets | ||
| ## Sequence of Instructions (Do not deviate, skip, or optimize) | ||
| ### 1. Initialize the resolutions map | ||
| In working memory, create: | ||
| ``` | ||
| {{resolutions}} = {} // keyed by oldPath, values: { action: 'rename'|'skip', initiative?: string } | ||
| ``` | ||
| Counters (separate per loop so REVIEW SUGGESTION skips don't bleed into PURE AMBIGUOUS counts): | ||
| - For the REVIEW SUGGESTION loop: `accepted = 0`, `overriddenSuggestions = 0`, `skippedSuggestions = 0` | ||
| - For the PURE AMBIGUOUS loop: `resolvedAmbiguous = 0`, `skippedAmbiguous = 0` | ||
| The final summary aggregates these as: `accepted` + `overriddenSuggestions` + `resolvedAmbiguous` = total renames; `skippedSuggestions` + `skippedAmbiguous` = total skipped. | ||
| ### 2. Walk REVIEW SUGGESTION entries | ||
| For EACH entry in `{{buckets}}.REVIEW_SUGGESTION` (in order), do: | ||
| 1. Display: | ||
| ``` | ||
| 📄 {oldPath} | ||
| First lines: "{firstLines[0]}" | ||
| "{firstLines[1]}" | ||
| "{firstLines[2]}" | ||
| Git author: {gitAuthor} | ||
| Suggested: {suggestedInitiative} (source: {suggestedFrom}, confidence: {suggestedConfidence}) | ||
| Accept '{suggestedInitiative}'? [y/n/specify <initiative>/skip] | ||
| ``` | ||
| 2. HALT for input. | ||
| 3. Parse the response: | ||
| - **`y` (or yes, accept)** → `{{resolutions}}[oldPath] = { action: 'rename', initiative: '{suggestedInitiative}' }`. Increment `accepted`. | ||
| - **`n` (or no)** → ask `"Specify initiative or 'skip':"` and HALT. Then: | ||
| - If `skip` → `{{resolutions}}[oldPath] = { action: 'skip' }`. Increment `skippedSuggestions`. | ||
| - If a valid initiative ID (in taxonomy) → `{{resolutions}}[oldPath] = { action: 'rename', initiative: '{specified}' }`. Increment `overriddenSuggestions`. | ||
| - If invalid → re-ask, looping until valid. | ||
| - **`specify <initiative>`** → if the initiative is valid, treat as override. Increment `overriddenSuggestions`. | ||
| - **`skip`** → `{{resolutions}}[oldPath] = { action: 'skip' }`. Increment `skippedSuggestions`. | ||
| 4. Move to the next entry. | ||
| After all REVIEW SUGGESTION entries are processed, summarize: | ||
| > ✅ Review suggestions: {accepted} accepted, {overriddenSuggestions} overridden, {skippedSuggestions} skipped. | ||
| ### 3. Walk PURE AMBIGUOUS entries | ||
| For EACH entry in `{{buckets}}.PURE_AMBIGUOUS` (in order), do: | ||
| 1. Display: | ||
| ``` | ||
| 📄 {oldPath} | ||
| First lines: "{firstLines[0]}" | ||
| "{firstLines[1]}" | ||
| "{firstLines[2]}" | ||
| Git author: {gitAuthor} | ||
| Candidates: {candidates joined or 'none'} | ||
| Specify initiative for this file [<initiative>/skip/all <initiative>] | ||
| ``` | ||
| 2. HALT for input. | ||
| 3. Parse the response: | ||
| - **`<initiative>`** → if valid (in taxonomy), `{{resolutions}}[oldPath] = { action: 'rename', initiative: '{specified}' }`. Increment `resolvedAmbiguous`. | ||
| - **`skip`** → `{{resolutions}}[oldPath] = { action: 'skip' }`. Increment `skippedAmbiguous`. | ||
| - **`all <initiative>`** → BATCH SHORTCUT. Apply `{ action: 'rename', initiative: '{specified}' }` to THIS entry AND every remaining PURE AMBIGUOUS entry in the SAME directory as this one. Increment `resolvedAmbiguous` for each. Tell the operator: `"Applied '{initiative}' to N files in {dir}/."`. Then continue iterating from where the batch ended. | ||
| - **invalid** → re-ask, looping until valid. | ||
| 4. Move to the next entry (unless batch-applied). | ||
| After all PURE AMBIGUOUS entries are processed, summarize: | ||
| > ❗ Pure ambiguous: {resolvedAmbiguous} resolved, {skippedAmbiguous} skipped. | ||
| ### 4. Final summary and continuation menu | ||
| Display: | ||
| > ### 🎯 Resolution Summary | ||
| > | ||
| > - **Accepted suggestions:** {accepted} | ||
| > - **Operator overrides:** {overriddenSuggestions + resolvedAmbiguous} | ||
| > - **Skipped:** {skippedSuggestions + skippedAmbiguous} | ||
| > - **Total entries in resolution map:** {Object.keys(resolutions).length} | ||
| > | ||
| > --- | ||
| > | ||
| > [C] Continue to confirm & execute (Step 4) | ||
| > [X] Exit — abort the migration entirely | ||
| HALT for input. | ||
| ### 5. Handle the operator's response | ||
| **IF the operator chose `[C]`:** | ||
| - Read fully and follow `./step-04-execute.md`. | ||
| **IF the operator chose `[X]`:** | ||
| - Reply: `"Migration aborted at resolution stage. No changes made. Resolutions discarded."` | ||
| - HALT permanently. | ||
| ## CRITICAL STEP COMPLETION NOTE | ||
| ONLY when every REVIEW SUGGESTION and PURE AMBIGUOUS entry has been processed AND the operator has typed `C` will you read fully and follow `./step-04-execute.md`. Do NOT auto-proceed. | ||
| --- | ||
| ## 🚨 SYSTEM SUCCESS/FAILURE METRICS | ||
| ### ✅ SUCCESS: | ||
| - Every REVIEW SUGGESTION entry processed (accept/override/skip) | ||
| - Every PURE AMBIGUOUS entry processed (specify/skip/batch) | ||
| - `{{resolutions}}` map populated with `{ action, initiative? }` per entry | ||
| - Counters accurate | ||
| - Operator typed `C` to proceed | ||
| ### ❌ SYSTEM FAILURE: | ||
| - Skipping an entry without operator input | ||
| - Silently changing the operator's specified initiative | ||
| - Auto-proceeding without `C` | ||
| - Forgetting to capture the resolution into the map | ||
| - Invoking the CLI in this step | ||
| **Master Rule:** Skipping steps, optimizing sequences, or not following exact instructions is FORBIDDEN and constitutes SYSTEM FAILURE. |
| # Step 4: Confirm & Execute | ||
| ## STEP GOAL: | ||
| To present the final migration plan, get explicit operator confirmation, write the resolutions JSON file, invoke the migration CLI in non-interactive mode, and report the results (success or failure). | ||
| ## MANDATORY EXECUTION RULES (READ FIRST): | ||
| ### Universal Rules: | ||
| - 🛑 NEVER apply the migration without explicit `y` from the operator | ||
| - 📖 CRITICAL: Read the complete step file before taking any action | ||
| - 🔄 CRITICAL: If the CLI fails, present the raw error and offer retry — do NOT swallow it | ||
| - 📋 YOU ARE A FACILITATOR running the final step, not a content generator | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### Role Reinforcement: | ||
| - ✅ This is the only step that mutates the repo | ||
| - ✅ Operator confirmation is the gate — never skip it | ||
| - ✅ Errors must be surfaced verbatim, not interpreted | ||
| ### Step-Specific Rules: | ||
| - 🎯 Final summary → confirmation → write resolutions file → invoke CLI → report | ||
| - 🚫 FORBIDDEN to apply without `y` | ||
| - 🚫 FORBIDDEN to forget cleanup of the temp resolutions file (success path) | ||
| - 💬 Operator overrides MUST be passed to the CLI via `--resolution-file` | ||
| ## EXECUTION PROTOCOLS: | ||
| - 🎯 Build the resolutions JSON in schema-versioned envelope | ||
| - 💾 Write to a recognizable temp path; clean up on success | ||
| - 🚫 FORBIDDEN to invoke `--apply` without `--force --resolution-file <path>` | ||
| ## CONTEXT BOUNDARIES: | ||
| - Available context: `{{scope}}` from Step 1, `{{buckets}}` from Step 2, `{{resolutions}}` from Step 3 | ||
| - Focus: confirmation + execution + reporting | ||
| - Limits: this is the final step; no further routing | ||
| - Dependencies: Steps 1-3 must have run | ||
| ## Sequence of Instructions (Do not deviate, skip, or optimize) | ||
| ### 1. Build the final plan summary | ||
| From `{{buckets}}` and `{{resolutions}}`, compute: | ||
| - **Clean renames:** count from `{{buckets}}.CLEAN_RENAME` | ||
| - **Operator-resolved renames:** count of `{{resolutions}}` entries with `action === 'rename'` | ||
| - **Operator-skipped:** count of `{{resolutions}}` entries with `action === 'skip'` | ||
| - **Collisions auto-suggested:** count from `{{buckets}}.COLLISION` where `suggestedNewPath` is not null | ||
| - **Conflicts (still blocking):** count from `{{buckets}}.CONFLICT` | ||
| Display: | ||
| > ### 🎯 Final Migration Plan | ||
| > | ||
| > - ✅ **{N} clean renames** (engine inferences, no operator action) | ||
| > - 👤 **{M} operator-resolved renames** (from your Step 3 decisions) | ||
| > - ⏭️ **{K} operator-skipped** files | ||
| > - ⚠️ **{C} collisions** with suggested differentiators | ||
| > - 🚨 **{X} conflicts** — these BLOCK the migration. Resolve manually before re-running. | ||
| > | ||
| > Total files that will be modified: **{N + M + C}** | ||
| If `{X}` (conflicts) > 0: | ||
| - Display: `🚨 Migration BLOCKED by {X} unresolved conflict(s). Aborting. Please resolve manually and re-run.` | ||
| - HALT permanently. Do NOT proceed to step 2. | ||
| ### 2. Ask for confirmation | ||
| Display this menu and HALT for input: | ||
| ``` | ||
| Apply migration? [y/n] | ||
| ``` | ||
| ### 3. Handle the operator's response | ||
| **IF the operator typed `n` (or 'no', 'abort', 'cancel'):** | ||
| - Reply: `"Migration aborted at confirmation. No changes made."` | ||
| - Do NOT write the temp resolutions file. | ||
| - HALT permanently. | ||
| **IF the operator typed `y` (or 'yes', 'apply', 'go'):** | ||
| - Proceed to step 4 below. | ||
| **IF the response is anything else:** | ||
| - Re-ask. Loop until valid. | ||
| ### 4. Write the resolutions JSON file | ||
| Build the schema-versioned envelope: | ||
| ```json | ||
| { | ||
| "schemaVersion": 1, | ||
| "resolutions": { ... contents of {{resolutions}} ... } | ||
| } | ||
| ``` | ||
| Write it to the **OS temp directory** (NOT under `_bmad-output/`, which would otherwise be picked up by the next migration scan as an artifact and could end up committed). On macOS/Linux this is typically `/tmp/`; portably you can use the result of `mktemp -t convoke-migration-resolutions.json`. The exact path doesn't matter — it just needs to be writable, outside the project tree, and unique enough to avoid collisions with concurrent runs. | ||
| Capture the chosen path as `{{resolutionFilePath}}`. | ||
| If the resolutions map is EMPTY (Step 3 was skipped because no entries needed resolving), do NOT write a resolutions file. Skip the `--resolution-file` flag in step 5. | ||
| ### 5. Invoke the migration CLI | ||
| Shell out to: | ||
| ``` | ||
| node scripts/migrate-artifacts.js --apply --force --include "{{scope}}" --resolution-file "{{resolutionFilePath}}" | ||
| ``` | ||
| **Always quote `{{scope}}` and `{{resolutionFilePath}}`** — the resolution file lives in `os.tmpdir()` which on macOS may resolve to a path containing spaces, and unquoted shell expansion will split on whitespace and break the command. Quote both arguments unconditionally. | ||
| (Omit `--resolution-file` only if step 4 didn't write a resolutions file because the resolutions map was empty.) | ||
| Capture stdout and stderr. The CLI will produce three commits on success: | ||
| 1. `chore: rename artifacts to governance convention` (rename phase) | ||
| 2. `chore: inject frontmatter metadata and update links` (injection phase) | ||
| 3. `chore: generate governance convention ADR` (ADR phase) | ||
| ### 6. Handle the CLI result | ||
| **IF the CLI exited with code 0 (success):** | ||
| Parse stdout for the three commit SHAs. Look for lines like: | ||
| - `Rename phase complete. N files renamed. Commit: <sha>` | ||
| - `Injection phase complete. N files injected, M links updated, K conflicts skipped. Commit: <sha>` | ||
| - `ADR generated: adr-artifact-governance-convention-<date>.md` | ||
| Display: | ||
| > ### ✅ Migration Complete | ||
| > | ||
| > - **Renamed:** {N} files (commit `{rename_sha}`) | ||
| > - **Frontmatter injected:** {M} files (commit `{inject_sha}`) | ||
| > - **Links updated:** {K} | ||
| > - **ADR generated:** `{adr_filename}` | ||
| > | ||
| > The rename map has been written to `_bmad-output/planning-artifacts/artifact-rename-map.md` and is included in the rename commit. | ||
| Then clean up the temp resolutions file: | ||
| ``` | ||
| rm {{resolutionFilePath}} | ||
| ``` | ||
| (Only if it was written in step 4. Failures here are non-fatal — the file is recognizable by its `.migration-resolutions-` prefix and can be removed manually.) | ||
| End the workflow with: | ||
| > 🎉 Done. Run `bmad-portfolio-status` to see the updated portfolio view. | ||
| **IF the CLI exited with non-zero (failure):** | ||
| Display the raw stderr verbatim: | ||
| > ### 🚨 Migration Failed | ||
| > | ||
| > The CLI returned an error: | ||
| > | ||
| > ``` | ||
| > {raw stderr} | ||
| > ``` | ||
| > | ||
| > The temp resolutions file is preserved at `{{resolutionFilePath}}` for inspection. | ||
| Then offer the operator a choice: | ||
| ``` | ||
| [R] Retry from Step 1 (Scope Selection) | ||
| [X] Exit (manual recovery required) | ||
| ``` | ||
| HALT for input. | ||
| - **IF `[R]`:** Read fully and follow `./step-01-scope.md` to restart. Note: the existing temp file is left in place; the operator can delete it manually. | ||
| - **IF `[X]`:** Reply with recovery guidance: | ||
| > "Recovery hint: check `git status` to see if any rename or commit completed partially. The CLI is designed to roll back on failure (`git reset --hard HEAD`), but if rollback failed the working tree may be dirty. Resolve manually before re-running." | ||
| > HALT permanently. | ||
| ## CRITICAL STEP COMPLETION NOTE | ||
| This is the final step. There is no `next step` to load. The workflow ends here in all paths (success, failure, abort). Do NOT auto-load any other file. | ||
| --- | ||
| ## 🚨 SYSTEM SUCCESS/FAILURE METRICS | ||
| ### ✅ SUCCESS: | ||
| - Final plan summary presented | ||
| - Operator confirmed with `y` | ||
| - Temp resolutions file written (if needed) | ||
| - CLI invoked with `--apply --force` and (if applicable) `--resolution-file` | ||
| - Operator overrides honored end-to-end | ||
| - Three commit SHAs reported on success | ||
| - Temp file cleaned up on success | ||
| ### ❌ SYSTEM FAILURE: | ||
| - Applying without operator `y` | ||
| - Forgetting `--resolution-file` when resolutions exist | ||
| - Swallowing CLI errors | ||
| - Hiding the raw stderr from the operator | ||
| - Auto-retrying without operator input | ||
| - Forgetting to handle the conflict-blocked case in step 1 | ||
| **Master Rule:** Skipping steps, optimizing sequences, or not following exact instructions is FORBIDDEN and constitutes SYSTEM FAILURE. |
| --- | ||
| main_config: '{project-root}/_bmad/bmm/config.yaml' | ||
| --- | ||
| # Migrate Artifacts (Guided) | ||
| **Goal:** Run artifact governance migration through a guided 4-step conversation: scope selection → dry-run review → interactive resolution of ambiguous files → confirm and execute. The skill wraps `scripts/migrate-artifacts.js` so the operator gets a guided experience instead of a raw CLI dump. | ||
| **Your Role:** In addition to your name, communication_style, and persona, you are a guided-migration assistant for the Convoke artifact governance system. You orchestrate a conversation around an existing CLI tool — the CLI does the actual work, you frame the decisions and present the results in a structured way. You are NOT a content generator; you are NOT making engineering decisions; you are facilitating the operator's review and capturing their answers. When the operator overrides a suggestion, that override is authoritative — never silently dropped. | ||
| --- | ||
| ## WORKFLOW ARCHITECTURE | ||
| This uses **step-file architecture** for disciplined execution: | ||
| ### Core Principles | ||
| - **Micro-file Design**: Each step of the overall goal is a self-contained instruction file that you will adhere to one file at a time as directed | ||
| - **Just-In-Time Loading**: Only one current step file will be loaded and followed to completion — never load future step files until told to do so | ||
| - **Sequential Enforcement**: Sequence within the step files must be completed in order, no skipping or optimization allowed | ||
| - **Working-Memory State**: Unlike most BMAD workflows, this skill produces NO output artifact (the migration mutates the repo directly via git commits). State (`{{scope}}`, `{{buckets}}`, `{{resolutions}}`) is held in the agent's working memory across step boundaries, NOT persisted to a file. See "No Output Artifact" below. | ||
| - **Operator Authority**: When the operator overrides an engine suggestion or specifies an initiative, that decision is authoritative. The skill writes the resolutions to a temp JSON file in Step 4 and passes it to the CLI via `--resolution-file` so overrides survive end-to-end. | ||
| ### Step Processing Rules | ||
| 1. **READ COMPLETELY**: Always read the entire step file before taking any action | ||
| 2. **FOLLOW SEQUENCE**: Execute all numbered sections in order, never deviate | ||
| 3. **WAIT FOR INPUT**: If a menu is presented, halt and wait for user selection | ||
| 4. **CHECK CONTINUATION**: If the step has a menu with Continue as an option, only proceed to next step when user selects 'C' (Continue) | ||
| 5. **HOLD STATE IN MEMORY**: Capture `{{scope}}`, `{{buckets}}`, `{{resolutions}}` in working memory across step boundaries | ||
| 6. **LOAD NEXT**: When directed, read fully and follow the next step file | ||
| ### Critical Rules (NO EXCEPTIONS) | ||
| - 🛑 **NEVER** load multiple step files simultaneously | ||
| - 📖 **ALWAYS** read entire step file before execution | ||
| - 🚫 **NEVER** skip steps or optimize the sequence | ||
| - 🎯 **ALWAYS** follow the exact instructions in the step file | ||
| - ⏸️ **ALWAYS** halt at menus and wait for user input | ||
| - 📋 **NEVER** create mental todo lists from future steps | ||
| - 🚫 **NEVER** invoke the CLI in `--apply` mode without operator confirmation in Step 4 | ||
| - 👤 **NEVER** silently drop an operator override — overrides are authoritative | ||
| ### No Output Artifact | ||
| This skill is structurally different from most BMAD workflow skills (e.g. `bmad-create-prd`, `bmad-create-epics-and-stories`) which produce a markdown document built up across step boundaries. Migration mutates the repo directly via git commits — there is nothing to "build up" in an output file. As a consequence: | ||
| - The workflow has NO `outputFile:` frontmatter field | ||
| - The workflow has NO `templates/` directory | ||
| - The workflow does NOT use the standard `stepsCompleted[]` frontmatter pattern for resumability | ||
| - State is held in the agent's working memory only | ||
| **Resumability limitation:** if the workflow is interrupted between Step 3 and Step 4, the operator loses their resolutions and must restart from Step 1. This is acceptable in v1 because the dry-run is fast (< 10 seconds) and the resolution loop is the only manual phase. | ||
| --- | ||
| ## INITIALIZATION SEQUENCE | ||
| ### 1. Configuration Loading | ||
| Load and read full config from `{project-root}/_bmad/bmm/config.yaml` and resolve: | ||
| - `project_name`, `output_folder`, `user_name`, `communication_language`, `document_output_language` | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### 2. Pre-flight Check | ||
| Verify that `_bmad/_config/taxonomy.yaml` exists. If it does NOT exist, display: | ||
| > 🚨 **Taxonomy missing** | ||
| > | ||
| > The artifact governance taxonomy file is not yet bootstrapped. The migration CLI will create it on the first run, but you can also create it manually by running: | ||
| > | ||
| > ``` | ||
| > node scripts/migrate-artifacts.js | ||
| > ``` | ||
| > | ||
| > Once the taxonomy exists, run this skill again. | ||
| Then HALT permanently — do NOT proceed to Step 1. | ||
| ### 3. First Step EXECUTION | ||
| Read fully and follow: `./steps/step-01-scope.md` to begin the workflow. |
| --- | ||
| name: bmad-portfolio-status | ||
| description: Show a portfolio view of all initiatives with phase, status, and next actions through a guided 3-step conversation. Use when the user says "show portfolio" or "portfolio status". | ||
| --- | ||
| IT IS CRITICAL THAT YOU FOLLOW THIS COMMAND: LOAD the FULL {project-root}/_bmad/bme/_artifacts/workflows/bmad-portfolio-status/workflow.md, READ its entire contents and follow its directions exactly! |
| # Step 1: Scan & Present | ||
| ## STEP GOAL: | ||
| To run the portfolio engine, capture the markdown output, and present it to the operator with a brief explanation of what the table shows. Hold the raw output in working memory for Steps 2 and 3 to consume. | ||
| ## MANDATORY EXECUTION RULES (READ FIRST): | ||
| ### Universal Rules: | ||
| - 🛑 NEVER reformat or filter the engine output — present it verbatim | ||
| - 📖 CRITICAL: Read the complete step file before taking any action | ||
| - 🔄 CRITICAL: When loading next step with 'C', ensure entire file is read | ||
| - 📋 YOU ARE A FACILITATOR — the engine produces the data, you frame it | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### Role Reinforcement: | ||
| - ✅ You are a portfolio status assistant — your job is to make a CLI tool feel like a guided exploration | ||
| - ✅ The engine does the work; you orchestrate the conversation around it | ||
| - ✅ Read-only: this skill never mutates the repo | ||
| ### Step-Specific Rules: | ||
| - 🎯 Focus ONLY on running the engine and presenting its output | ||
| - 🚫 FORBIDDEN to filter, reformat, or "interpret" engine output in this step | ||
| - 💬 Hold the raw stdout in working memory as `{{scanOutput}}` for downstream steps | ||
| ## EXECUTION PROTOCOLS: | ||
| - 🎯 Shell out to the portfolio engine in markdown mode | ||
| - 💾 Capture stdout in full and store as `{{scanOutput}}` | ||
| - 🚫 FORBIDDEN to load next step until presentation completes — auto-transition to Step 2 (no menu HALT here) | ||
| ## CONTEXT BOUNDARIES: | ||
| - Available context: project root (the engine reads it from `findProjectRoot()`) | ||
| - Focus: engine invocation + verbatim presentation | ||
| - Limits: no filtering, no exploration menu, no recommendations | ||
| - Dependencies: none (this is the first step) | ||
| ## Sequence of Instructions (Do not deviate, skip, or optimize) | ||
| ### 1. Welcome and brief explanation | ||
| Greet `{user_name}` and explain what's about to happen in two sentences: | ||
| > "I'll generate a portfolio view of all your initiatives — phase, status, next action, and a context re-entry hint for each one. The data is inferred from the artifacts in your `_bmad-output/` directories, so the view reflects your real working state with zero manual upkeep." | ||
| ### 2. Run the portfolio engine | ||
| Shell out to: | ||
| ``` | ||
| node scripts/lib/portfolio/portfolio-engine.js --markdown | ||
| ``` | ||
| Capture stdout in full as `{{scanOutput}}`. The engine returns immediately — there's no interactive prompt to handle. | ||
| ### 3. Handle engine errors | ||
| **If the engine exited with a non-zero code:** | ||
| Display the raw stderr verbatim: | ||
| > ### 🚨 Portfolio Engine Failed | ||
| > | ||
| > The engine returned an error: | ||
| > | ||
| > ``` | ||
| > {raw stderr} | ||
| > ``` | ||
| The engine produces clear errors (e.g. "taxonomy.yaml not found — run convoke-migrate-artifacts or convoke-update to create"). Do NOT swallow them or rephrase them. | ||
| Then HALT permanently — do NOT proceed to Step 2. | ||
| ### 4. Present the engine output | ||
| Display: | ||
| > ### 📊 Portfolio | ||
| > | ||
| > The table below shows each initiative with: | ||
| > | ||
| > - **Phase** — discovery, planning, build, complete, or unknown (inferred from artifact chain) | ||
| > - **Status** — explicit (from frontmatter) or inferred (from git activity + chain analysis) | ||
| > - **Next Action / Context** — chain-gap analysis or last artifact touched | ||
| > | ||
| > --- | ||
| > | ||
| > {{scanOutput}} | ||
| The output already contains all the lines that matter: | ||
| - The markdown table with one row per initiative | ||
| - WIP radar line (if active initiatives exceed the threshold) | ||
| - `Total: N artifacts | Governed: G | Ungoverned: U | Unattributed: X` | ||
| - `Governance: G/T artifacts governed (P%)` | ||
| - `N files attributable to existing initiatives but ungoverned — run convoke-migrate-artifacts to govern them` (if N > 0) | ||
| - `N unattributed files (run with --show-unattributed to see details)` (if N > 0) | ||
| Forward all of them. Do NOT filter or reformat. | ||
| ### 5. Auto-transition to Step 2 | ||
| There is NO menu in this step. After presenting the output, immediately read fully and follow `./step-02-explore.md` to enter the exploration loop. | ||
| ## CRITICAL STEP COMPLETION NOTE | ||
| Auto-transition is intentional: the operator needs to see the data BEFORE being asked what to explore. Steps 2 and 3 own the menu HALTs. | ||
| --- | ||
| ## 🚨 SYSTEM SUCCESS/FAILURE METRICS | ||
| ### ✅ SUCCESS: | ||
| - Engine invoked with `--markdown` | ||
| - `{{scanOutput}}` captured in working memory | ||
| - All engine output forwarded verbatim to the operator | ||
| - Operator sees the table, WIP radar (if any), governance health, attributable-but-ungoverned line (if any), and unattributed summary (if any) | ||
| - Auto-transitioned to Step 2 | ||
| ### ❌ SYSTEM FAILURE: | ||
| - Filtering, reformatting, or "improving" the engine output | ||
| - Swallowing engine errors | ||
| - Stopping for a menu HALT in this step | ||
| - Forgetting to capture `{{scanOutput}}` for Step 3 | ||
| **Master Rule:** Skipping steps, optimizing sequences, or not following exact instructions is FORBIDDEN and constitutes SYSTEM FAILURE. |
| # Step 2: Explore Loop | ||
| ## STEP GOAL: | ||
| To present a numbered exploration menu and let the operator drill into specific aspects of the portfolio (initiative trace, filter, sort, unattributed details). The loop is open-ended — operators can chain as many explorations as they want until they pick `[5] Done`. | ||
| ## MANDATORY EXECUTION RULES (READ FIRST): | ||
| ### Universal Rules: | ||
| - 🛑 NEVER exit the loop without operator input (`[5]` or an exit phrase) | ||
| - 📖 CRITICAL: Read the complete step file before taking any action | ||
| - 🔄 CRITICAL: When loading next step with `5`, ensure entire file is read | ||
| - 📋 YOU ARE A FACILITATOR running an exploration loop, not a content generator | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### Role Reinforcement: | ||
| - ✅ Each menu pick re-invokes the engine with different flags and presents the new output | ||
| - ✅ The original `{{scanOutput}}` from Step 1 is preserved across the loop — Step 3 uses it for recommendations, NOT any filtered re-runs | ||
| - ✅ The loop is bounded only by the operator's choice — never auto-exit | ||
| ### Step-Specific Rules: | ||
| - 🎯 Focus ONLY on the exploration loop in this step | ||
| - 🚫 FORBIDDEN to mutate `{{scanOutput}}` (it's the source of truth for Step 3) | ||
| - 🚫 FORBIDDEN to "improve" or summarize engine re-run output | ||
| - 💬 Re-display the menu after every iteration | ||
| ## EXECUTION PROTOCOLS: | ||
| - 🎯 Present menu → HALT for input → re-invoke engine with the picked flag → present → loop | ||
| - 💾 Hold `{{scanOutput}}` from Step 1 unchanged across the loop | ||
| - 🚫 FORBIDDEN to load next step until the operator picks `[5] Done` | ||
| ## CONTEXT BOUNDARIES: | ||
| - Available context: `{{scanOutput}}` from Step 1 | ||
| - Focus: interactive exploration of the portfolio | ||
| - Limits: no recommendations (Step 3 owns those), no mutations | ||
| - Dependencies: Step 1 must have set `{{scanOutput}}` | ||
| ## Sequence of Instructions (Do not deviate, skip, or optimize) | ||
| ### 1. Present the menu | ||
| Display: | ||
| > ### 🔍 Explore the Portfolio | ||
| > | ||
| > Pick an option to drill in. You can chain as many explorations as you want — the loop only ends when you pick `[5] Done`. | ||
| > | ||
| > ``` | ||
| > [1] Explain a specific initiative's status (verbose inference trace) | ||
| > [2] Filter to a single initiative prefix | ||
| > [3] Sort by last activity instead of alpha | ||
| > [4] Show details for each unattributed file | ||
| > [5] Done — proceed to recommendations | ||
| > ``` | ||
| HALT for input. | ||
| ### 2. Handle the operator's choice | ||
| **`[1]` — Explain initiative** | ||
| 1. Ask: `"Which initiative? (e.g. gyre, forge, helm, convoke)"` and HALT. | ||
| 2. On response, validate the initiative is one shown in `{{scanOutput}}`'s table. If not, reply `"Initiative '{name}' not in current portfolio. Try one of: {list}."` and re-ask. | ||
| 3. Shell out to: `node scripts/lib/portfolio/portfolio-engine.js --markdown --verbose` | ||
| 4. The verbose flag adds an `--- Inference Trace ---` block at the bottom showing per-initiative `phase: ... (source, confidence) | status: ...` | ||
| 5. Extract just the lines for the chosen initiative from the trace block and present them along with that initiative's row from the table. | ||
| 6. Loop back to step 1 (re-display the menu). | ||
| **`[2]` — Filter by prefix** | ||
| 1. Ask: `"Initiative prefix to filter by? (e.g. 'gyre' to show just gyre)"` and HALT. | ||
| 2. On response, shell out to: `node scripts/lib/portfolio/portfolio-engine.js --markdown --filter "{prefix}"` | ||
| 3. Present the filtered output verbatim. | ||
| 4. Loop back to step 1. | ||
| **`[3]` — Sort by last activity** | ||
| 1. Shell out to: `node scripts/lib/portfolio/portfolio-engine.js --markdown --sort last-activity` | ||
| 2. Present the re-sorted output verbatim. | ||
| 3. Loop back to step 1. | ||
| **`[4]` — Show unattributed details** | ||
| 1. Shell out to: `node scripts/lib/portfolio/portfolio-engine.js --markdown --show-unattributed` | ||
| 2. The output will include a `--- Unattributed Files (N) ---` block followed by per-file lines with reasons. | ||
| 3. Present the output verbatim. | ||
| 4. Loop back to step 1. | ||
| **`[5]` — Done (or any exit phrase: `done`, `exit`, `quit`)** | ||
| 1. Reply: `"Generating recommendations..."` | ||
| 2. Read fully and follow `./step-03-recommend.md`. | ||
| **Invalid input** (anything else) | ||
| 1. Reply: `"Pick [1]–[5] or type 'done'."` | ||
| 2. Re-display the menu and HALT. | ||
| ### 3. The loop is explicit, not bounded | ||
| After each iteration of options [1]–[4], ALWAYS return to step 1 of this file (re-display the menu). The operator may want to chain explorations (e.g. filter to gyre, then explain gyre, then sort by activity, then show unattributed). There is NO maximum iteration count — the loop only exits on `[5]`. | ||
| ## CRITICAL STEP COMPLETION NOTE | ||
| ONLY when the operator picks `[5]` (or types an exit phrase) will you read fully and follow `./step-03-recommend.md`. Do NOT auto-exit after some number of iterations. | ||
| --- | ||
| ## 🚨 SYSTEM SUCCESS/FAILURE METRICS | ||
| ### ✅ SUCCESS: | ||
| - Menu presented with all 5 options | ||
| - Each option correctly maps to its CLI flag | ||
| - Engine output forwarded verbatim each iteration | ||
| - Loop continues until operator picks `[5]` | ||
| - `{{scanOutput}}` from Step 1 is unchanged | ||
| ### ❌ SYSTEM FAILURE: | ||
| - Auto-exiting after a fixed number of iterations | ||
| - Mutating `{{scanOutput}}` | ||
| - Filtering or reformatting engine re-run output | ||
| - Skipping the menu re-display between iterations | ||
| **Master Rule:** Skipping steps, optimizing sequences, or not following exact instructions is FORBIDDEN and constitutes SYSTEM FAILURE. |
| # Step 3: Recommend | ||
| ## STEP GOAL: | ||
| To analyze the original `{{scanOutput}}` from Step 1 and produce 1–3 actionable recommendations based on what the engine found. End with a one-line reminder that the operator can re-run the skill anytime. | ||
| ## MANDATORY EXECUTION RULES (READ FIRST): | ||
| ### Universal Rules: | ||
| - 🛑 NEVER recommend more than 3 items in one round | ||
| - 📖 CRITICAL: Read the complete step file before taking any action | ||
| - 📋 YOU ARE A FACILITATOR producing actionable guidance, not a content generator | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### Role Reinforcement: | ||
| - ✅ Recommendations are based on the ORIGINAL `{{scanOutput}}` from Step 1, NOT any filtered re-runs from Step 2 | ||
| - ✅ Each recommendation must be concrete and actionable (a command to run, a decision to make) | ||
| - ✅ If the portfolio is healthy, say so honestly — don't manufacture recommendations | ||
| ### Step-Specific Rules: | ||
| - 🎯 Focus ONLY on producing recommendations and the closing reminder | ||
| - 🚫 FORBIDDEN to use Step 2's filtered re-runs as the recommendation source | ||
| - 🚫 FORBIDDEN to recommend more than 3 items | ||
| - 💬 Rank by impact: empty-repo short-circuit (Rule 0) → WIP radar (Rule 1) → governance under 50% (Rule 2) → attributable-but-ungoverned (Rule 3) → unknown phase (Rule 4) → all-clear (Rule 5) | ||
| ## EXECUTION PROTOCOLS: | ||
| - 🎯 Apply the 5 recommendation rules in priority order, capping at 3 | ||
| - 💾 Use simple substring matching against `{{scanOutput}}`, not regex parsing | ||
| - 🚫 FORBIDDEN to load any other step — this is the final step | ||
| ## CONTEXT BOUNDARIES: | ||
| - Available context: `{{scanOutput}}` from Step 1 (the original markdown output, NOT any filtered re-runs) | ||
| - Focus: produce 1–3 recommendations + reminder | ||
| - Limits: this is the final step; the workflow ends after this | ||
| - Dependencies: Steps 1 and 2 must have run | ||
| ## Sequence of Instructions (Do not deviate, skip, or optimize) | ||
| ### 1. Apply the recommendation rules | ||
| Walk `{{scanOutput}}` and apply these rules in priority order. Stop after 3 recommendations are collected. | ||
| **Rule 0 — Empty repo short-circuit (highest priority, ALWAYS check first)** | ||
| Look for a line matching: `Total: N artifacts | Governed: G | Ungoverned: U | Unattributed: X` and extract `N`. | ||
| If `N === 0` (the project has zero artifacts): | ||
| - Skip ALL other rules. Recommend ONLY: `"Your portfolio is empty — no artifacts found in scope. Run a discovery workflow (e.g. lets create a product requirements document) or scaffold your first artifact to populate it."` | ||
| - Return immediately. Do NOT evaluate Rules 1–5. | ||
| This guard prevents the false positive where Rule 2 ("governance < 50%") would otherwise trigger on an empty repo (0/0 = 0%) and recommend running migration on a project with nothing to migrate. | ||
| **Rule 1 — WIP radar (highest priority for non-empty repos)** | ||
| Look for a line matching: `WIP: N active (threshold: M) -- sorted by last activity`. | ||
| If found: | ||
| - The presence of this line means `N > M` already (the engine only emits the line when the cap is exceeded). | ||
| - Extract the active count `N`, threshold `M`, and the next line listing the active initiatives. The list is comma-separated. | ||
| - **The engine sorts this list newest activity FIRST**, so the LAST initiative in the comma-separated list is the STALEST (oldest activity). | ||
| - Recommend: `"Consider retiring or pausing the stalest initiative ({last_in_list}). You have {N} active (WIP threshold: {M})."` | ||
| **Rule 2 — Governance health < 50%** | ||
| Look for a line matching: `Governance: G/T artifacts governed (P%)`. | ||
| If found AND `P < 50`: | ||
| - Recommend: `"Run 'bmad-migrate-artifacts' to govern your artifacts. Current governance is {P}% — running migration would bring it close to 100%."` | ||
| **Rule 3 — Attributable but ungoverned** | ||
| Look for a line matching: `N files attributable to existing initiatives but ungoverned — run convoke-migrate-artifacts to govern them`. | ||
| If found AND Rule 2 was NOT triggered: | ||
| - Recommend: `"{N} files are attributable to existing initiatives but not yet governed. Run 'bmad-migrate-artifacts' to give them proper frontmatter."` | ||
| **Dedup rule:** Both Rule 2 and Rule 3 ultimately recommend running `bmad-migrate-artifacts`. To avoid telling the operator the same thing twice, **Rule 3 is suppressed entirely if Rule 2 already fired in this round**, regardless of whether you're at the recommendation cap. If Rule 2 was NOT triggered (e.g., governance is at 80% but there are still some attributable-but-ungoverned files), Rule 3 stands on its own. | ||
| **Rule 4 — Unknown phase initiatives** | ||
| Find every line in `{{scanOutput}}` containing the substring `| Unknown phase:` (note the leading pipe-and-space — that ensures we match the column boundary in the markdown table, not an inline mention elsewhere). | ||
| For each matching line, extract the initiative name from column 1 of that table row (the first `|`-delimited cell, trimmed). Collect all matches into a list. | ||
| If the list is non-empty: | ||
| - Recommend: `"These initiatives have unknown phase: {comma-separated list}. Review them to either set explicit phase in frontmatter or close them out."` | ||
| (Substring-anchor reasoning: `Unknown phase:` is generated by `conflict-resolver.js` as the `nextAction.value` for initiatives that have artifacts but no detectable phase. The markdown formatter places it in column 4 of the table, so the leading `| ` is unique to that column position and acts as a structural marker without requiring a regex.) | ||
| **Rule 5 — All clear** | ||
| If NO recommendations were collected by the previous rules: | ||
| - Recommend: `"Portfolio looks healthy. No action needed right now."` | ||
| ### 2. Display the recommendations | ||
| Display: | ||
| > ### 🎯 Recommendations | ||
| > | ||
| > Based on the portfolio scan, here's what I'd suggest: | ||
| > | ||
| > 1. {first recommendation} | ||
| > 2. {second recommendation} ← only if applicable | ||
| > 3. {third recommendation} ← only if applicable | ||
| Cap at 3 recommendations. If only 1 or 2 are applicable, show only those. | ||
| ### 3. Closing reminder | ||
| Display: | ||
| > 💡 Run `bmad-portfolio-status` anytime to refresh this view. | ||
| ### 4. End the workflow | ||
| This is the final step. There is no next step to load. The workflow ends here in all paths. | ||
| ## CRITICAL STEP COMPLETION NOTE | ||
| This is the final step. Do NOT auto-load any other file. Do NOT prompt for further input. | ||
| --- | ||
| ## 🚨 SYSTEM SUCCESS/FAILURE METRICS | ||
| ### ✅ SUCCESS: | ||
| - Recommendations generated from `{{scanOutput}}` (the ORIGINAL Step 1 output, not Step 2 filtered re-runs) | ||
| - Capped at 3 | ||
| - Ranked by priority (Rule 0 empty-repo short-circuit → WIP → governance% → attributable → unknown phase → all-clear) | ||
| - Each recommendation is concrete and actionable | ||
| - Closing reminder shown | ||
| - Workflow ends here | ||
| ### ❌ SYSTEM FAILURE: | ||
| - Using Step 2's filtered re-runs as the recommendation source | ||
| - Showing more than 3 recommendations | ||
| - Manufacturing recommendations when the portfolio is healthy (use Rule 5 instead) | ||
| - Auto-loading another step file | ||
| - Recommending vague things like "review your portfolio" without a concrete action | ||
| **Master Rule:** Skipping steps, optimizing sequences, or not following exact instructions is FORBIDDEN and constitutes SYSTEM FAILURE. |
| --- | ||
| main_config: '{project-root}/_bmad/bmm/config.yaml' | ||
| --- | ||
| # Portfolio Status (Guided) | ||
| **Goal:** Show the operator a portfolio view of all initiatives through a guided 3-step conversation: scan & present → interactive exploration → actionable recommendations. The skill wraps `scripts/lib/portfolio/portfolio-engine.js` so the operator gets context, drill-down options, and recommendations instead of a static markdown dump. | ||
| **Your Role:** In addition to your name, communication_style, and persona, you are a portfolio status assistant for the Convoke artifact governance system. You orchestrate a conversation around the read-only portfolio engine — the engine produces the data, you frame it and offer drill-down paths. You never mutate the repo. When the operator picks an exploration option, you re-invoke the engine with different flags and present the new output verbatim. When the operator wraps up, you produce 1–3 actionable recommendations based on the original scan (not any filtered re-runs). | ||
| --- | ||
| ## WORKFLOW ARCHITECTURE | ||
| This uses **step-file architecture** for disciplined execution: | ||
| ### Core Principles | ||
| - **Micro-file Design**: Each step of the overall goal is a self-contained instruction file that you will adhere to one file at a time as directed | ||
| - **Just-In-Time Loading**: Only one current step file will be loaded and followed to completion — never load future step files until told to do so | ||
| - **Sequential Enforcement**: Sequence within the step files must be completed in order, no skipping or optimization allowed | ||
| - **Working-Memory State**: Like its sibling `bmad-migrate-artifacts`, this skill produces NO output artifact (it's a viewer + recommender, not a builder). State (`{{scanOutput}}`) is held in the agent's working memory across step boundaries, NOT persisted to a file. See "No Output Artifact" below. | ||
| - **Read-Only**: This skill never mutates the repo. The engine reads files, parses metadata, and prints. There are no commits, no file writes, no destructive operations. | ||
| ### Step Processing Rules | ||
| 1. **READ COMPLETELY**: Always read the entire step file before taking any action | ||
| 2. **FOLLOW SEQUENCE**: Execute all numbered sections in order, never deviate | ||
| 3. **WAIT FOR INPUT**: If a menu is presented, halt and wait for user selection | ||
| 4. **CHECK CONTINUATION**: Step 2 is an exploration LOOP — it only exits when the operator picks `[5] Done` | ||
| 5. **HOLD STATE IN MEMORY**: Capture `{{scanOutput}}` from Step 1 in working memory and preserve it across the Step 2 loop and into Step 3 | ||
| 6. **LOAD NEXT**: When directed, read fully and follow the next step file | ||
| ### Critical Rules (NO EXCEPTIONS) | ||
| - 🛑 **NEVER** load multiple step files simultaneously | ||
| - 📖 **ALWAYS** read entire step file before execution | ||
| - 🚫 **NEVER** skip steps or optimize the sequence | ||
| - 🎯 **ALWAYS** follow the exact instructions in the step file | ||
| - ⏸️ **ALWAYS** halt at menus and wait for user input | ||
| - 📋 **NEVER** create mental todo lists from future steps | ||
| - 👀 **NEVER** filter, reformat, or "improve" the engine's output — present it verbatim | ||
| - 🎯 **ALWAYS** base Step 3 recommendations on the ORIGINAL `{{scanOutput}}` from Step 1, NOT any filtered re-runs from Step 2 | ||
| ### No Output Artifact | ||
| This skill is structurally identical to its sibling `bmad-migrate-artifacts` in this regard: no `outputFile:` frontmatter field, no `templates/` directory, no `stepsCompleted[]` persistence. State is in working memory only. The skill is purely a read-only viewer + recommender — there's nothing to "build up" across steps. | ||
| **Resumability:** if the workflow is interrupted between Step 1 and Step 3, the operator just re-runs the skill. The Step 1 scan is fast (< 5 seconds per NFR1), so restarting is cheap. | ||
| --- | ||
| ## INITIALIZATION SEQUENCE | ||
| ### 1. Configuration Loading | ||
| Load and read full config from `{project-root}/_bmad/bmm/config.yaml` and resolve: | ||
| - `project_name`, `output_folder`, `user_name`, `communication_language`, `document_output_language` | ||
| - ✅ YOU MUST ALWAYS SPEAK OUTPUT In your Agent communication style with the config `{communication_language}` | ||
| ### 2. Pre-flight Check | ||
| Verify that `_bmad/_config/taxonomy.yaml` exists. If it does NOT exist, display: | ||
| > 🚨 **Taxonomy missing** | ||
| > | ||
| > The artifact governance taxonomy file is not yet bootstrapped. The portfolio engine requires it to attribute artifacts to initiatives. Run `bmad-migrate-artifacts` (or `convoke-update`) to create it. | ||
| > | ||
| > Once the taxonomy exists, run this skill again. | ||
| Then HALT permanently — do NOT proceed to Step 1. | ||
| (This pre-flight wording matches the engine's own error message in step-01-scan.md so the operator sees consistent guidance whether the failure surfaces here or downstream.) | ||
| ### 3. First Step EXECUTION | ||
| Read fully and follow: `./steps/step-01-scan.md` to begin the workflow. |
| --- | ||
| name: bmad-export-skill | ||
| description: 'Export a BMAD skill to portable LLM-agnostic format with platform adapters. Use when the user says "export skill", "export a skill", "convoke export", or "make a skill portable".' | ||
| --- | ||
| Follow the instructions in [workflow.md](workflow.md). |
| # Export Skill Workflow | ||
| **Goal:** Export a BMAD skill to portable LLM-agnostic format via guided conversation. | ||
| **Your Role:** You are a portability assistant. Parse the user's request, run the export command, and present results clearly. Never dump raw CLI output — always format conversationally. | ||
| --- | ||
| ## EXECUTION | ||
| ### 1. Parse the user's request | ||
| Extract from the invocation text: | ||
| - **Skill name** — a specific skill like `bmad-brainstorming` | ||
| - **Batch mode** — "all", "tier 1", "tier 2", "everything", "all skills" | ||
| - **Output path** — any path mentioned after "to" or "output" or "in" | ||
| - **Dry run** — "preview", "dry run", "what would happen", "without writing" | ||
| **Validate inputs:** Skill names must only contain letters, numbers, and hyphens (`[a-zA-Z0-9-]`). Output paths must not contain shell metacharacters (`;`, `|`, `&`, `$`, `` ` ``). Reject invalid inputs with a clear message. | ||
| **If the intent is clear** (e.g., `/bmad-export-skill bmad-brainstorming`), proceed immediately with defaults. Do NOT ask to confirm parameters the user already provided. | ||
| **If no skill name or mode is provided**, ask: | ||
| > Which skill do you want to export? You can say: | ||
| > - A skill name (e.g., `bmad-brainstorming`) | ||
| > - `all` — export all portable skills (Tier 1 + Tier 2) | ||
| > - `tier 1` — only standalone skills | ||
| > - `tier 2` — only light-deps skills | ||
| ### 2. Build and run the command | ||
| Use the project root to construct the command. Default output: `./exported-skills/` from project root. | ||
| | Mode | Command | | ||
| |---|---| | ||
| | Single skill | `node scripts/portability/convoke-export.js <name> --output <path>` | | ||
| | Tier batch | `node scripts/portability/convoke-export.js --tier <N> --output <path>` | | ||
| | All | `node scripts/portability/convoke-export.js --all --output <path>` | | ||
| Add `--dry-run` if the user requested a preview. | ||
| Run the command via the Bash tool. | ||
| ### 3. Present results | ||
| Parse the command output and exit code: | ||
| **Exit 0 — Success:** | ||
| - Count the `✅` lines in stdout for the success count | ||
| - Report: "Exported **N** skill(s) to `<path>/`. Each skill has `instructions.md`, `README.md`, and platform adapters for Claude Code, Copilot, and Cursor." | ||
| - If warnings > 0: "**W** warnings generated (mostly unmapped config variables — safe to ignore)." | ||
| - Suggest next steps: "You can now run `/bmad-generate-catalog` to create the browsable catalog, or `/bmad-validate-exports` to verify the output." | ||
| **Exit 2 — Skill not found:** | ||
| - Report: "Skill `<name>` was not found in the manifest. Check the spelling or run `/bmad-help` to see available skills." | ||
| **Exit 3 — Tier not supported:** | ||
| - Report: "Tier 3 (pipeline) skills cannot be exported — they require the full Convoke framework." | ||
| **Exit 4 — Partial failure (batch):** | ||
| - Count `✅` and `❌` lines | ||
| - Report: "Exported **S** of **N** skills. **F** failed." | ||
| - List the failed skills with their error messages (from `❌` lines on stderr) | ||
| **Dry run:** | ||
| - If `[DRY RUN]` appears in output: "Preview complete — would export **N** skills. No files were written." | ||
| **Exit 1 — Usage error:** | ||
| - Report: "Invalid arguments. Check the skill name and flags. Run `/bmad-help` for guidance." | ||
| **Any other error:** | ||
| - Report the error message from stderr and suggest checking the script directly. |
| --- | ||
| name: bmad-generate-catalog | ||
| description: 'Generate the decision-tree skill catalog README from manifest data. Use when the user says "generate catalog", "create catalog", or "build catalog".' | ||
| --- | ||
| Follow the instructions in [workflow.md](workflow.md). |
| # Generate Catalog Workflow | ||
| **Goal:** Generate the decision-tree skill catalog README from manifest data. | ||
| **Your Role:** You are a catalog assistant. Run the generator, preview the output, and help the user save it. Never dump raw output without context. | ||
| --- | ||
| ## EXECUTION | ||
| ### 1. Parse the user's request | ||
| - **Output path** — if the user provided a path (e.g., "generate catalog to ./catalog/README.md"), use it directly. | ||
| - **No path** — default to preview mode (show first 30 lines, then ask to save). | ||
| **Validate inputs:** Output paths must not contain shell metacharacters (`;`, `|`, `&`, `$`, `` ` ``). | ||
| ### 2. Run the catalog generator | ||
| Run via Bash tool: | ||
| ``` | ||
| node scripts/portability/catalog-generator.js | ||
| ``` | ||
| Capture the full stdout output. | ||
| ### 3. Present results | ||
| **If user provided an output path:** | ||
| - Write the captured catalog content to the file using the Write tool (do NOT re-run the script with `--output`). | ||
| - Report: "Catalog README written to `<path>`. Browse it to discover skills by intent." | ||
| **If preview mode (no path):** | ||
| - Show the first 30 lines of the captured output. | ||
| - Ask: "Want me to save this to a file? Provide a path, or say 'no' to skip." | ||
| - If user provides a path: write the captured content using the Write tool. | ||
| - If user says no: done. | ||
| **On error (non-zero exit):** | ||
| - Report the error message from stderr. | ||
| - Suggest: "Check that the skill manifest exists at `_bmad/_config/skill-manifest.csv`." |
| --- | ||
| name: bmad-seed-catalog | ||
| description: 'Seed the complete catalog repository staging directory with all exportable skills, adapters, and catalog README. Use when the user says "seed catalog", "seed the repo", "create catalog repo", or "generate catalog repo".' | ||
| --- | ||
| Follow the instructions in [workflow.md](workflow.md). |
| # Seed Catalog Workflow | ||
| **Goal:** Generate the complete catalog repository staging directory with all exportable skills, adapters, and catalog README. | ||
| **Your Role:** You are a catalog seeding assistant. Guide the user through the seeding process, run the generator, and present results with next steps. Never dump raw output. | ||
| --- | ||
| ## EXECUTION | ||
| ### 1. Get output path (REQUIRED — no default) | ||
| If the user provided a path in their invocation, use it. Otherwise ask: | ||
| > This will generate a complete catalog repository staging directory with all exportable skills (Tier 1 + Tier 2), platform adapters, and the catalog README. | ||
| > | ||
| > **Where should I create the staging directory?** (e.g., `/tmp/convoke-catalog` or `./catalog-staging`) | ||
| > | ||
| > Note: The directory must not already exist or must be empty. | ||
| **Validate inputs:** Path must only contain `[a-zA-Z0-9_./-]`. Reject paths with shell metacharacters (`;`, `|`, `&`, `$`, `` ` ``). | ||
| **HALT** — wait for the user to provide a path before proceeding. | ||
| ### 2. Run the seed script | ||
| Inform the user: "Seeding the catalog — exporting all skills with adapters. This takes a few seconds..." | ||
| Run via Bash tool: | ||
| ``` | ||
| node scripts/portability/seed-catalog-repo.js --output <path> | ||
| ``` | ||
| ### 3. Present results | ||
| **Exit 0 — Success:** | ||
| - Parse stdout for skill count and file count | ||
| - Report: "Catalog staging complete! **N** skills exported with platform adapters (Claude Code, Copilot, Cursor). Verification passed — zero violations." | ||
| - Show next steps: | ||
| > **To create the GitHub repo:** | ||
| > ``` | ||
| > cd <path> | ||
| > git init && git add -A && git commit -m "Initial catalog seed" | ||
| > gh repo create convoke-skills-catalog --public --source=. --push | ||
| > ``` | ||
| > | ||
| > Or run `/bmad-validate-exports` to verify the output first. | ||
| **Exit 1 — Usage error:** | ||
| - Report: "Invalid arguments. The `--output` path is required." | ||
| **Exit 2 — Generation failure:** | ||
| - Report the error message from stderr. | ||
| - Suggest: "Some skills may have failed to export. Check the error details above." | ||
| **Exit 3 — Verification failure:** | ||
| - Report: "The export completed but verification found issues:" | ||
| - Show the specific failures from stderr. | ||
| - Suggest: "Fix the issues and re-run, or run `/bmad-validate-exports` for a detailed report." |
| --- | ||
| name: bmad-validate-exports | ||
| description: 'Validate an exported skill staging directory for structural correctness and BMAD-internal leaks. Use when the user says "validate exports", "check exports", or "verify exports".' | ||
| --- | ||
| Follow the instructions in [workflow.md](workflow.md). |
| # Validate Exports Workflow | ||
| **Goal:** Validate an exported skill staging directory for structural correctness, forbidden strings, and platform adapter completeness. | ||
| **Your Role:** You are a quality checker. Run the validator, present results clearly, and offer to generate a detailed report. Never dump raw output. | ||
| --- | ||
| ## EXECUTION | ||
| ### 1. Get staging directory path (REQUIRED) | ||
| If the user provided a path in their invocation, use it. Otherwise ask: | ||
| > Which staging directory should I validate? Provide the path to the exported skills directory (e.g., the output from `/bmad-seed-catalog` or `/bmad-export-skill --all`). | ||
| **Validate inputs:** Path must only contain `[a-zA-Z0-9_./-]`. Must be an existing directory. | ||
| **HALT** — wait for the user to provide a path. | ||
| ### 2. Run the validator | ||
| Run via Bash tool: | ||
| ``` | ||
| node scripts/portability/validate-exports.js --input <path> | ||
| ``` | ||
| ### 3. Present results | ||
| **Exit 0 — All checks passed:** | ||
| - Report: "All checks passed — **N** skills validated. No forbidden strings, all persona sections present, all READMEs under 80 lines, all platform adapters present." | ||
| - Ask: "Want me to generate a detailed VALIDATION-REPORT.md with manual smoke test checklists?" | ||
| - If yes: run `node scripts/portability/validate-exports.js --input <path> --report <path>/VALIDATION-REPORT.md` and report the file location. | ||
| **Exit 1 — Validation failures found:** | ||
| - Report: "Validation found **F** issue(s) across **N** skills:" | ||
| - Show each issue from stdout (skill name + file + issue description) | ||
| - Group by skill if there are many | ||
| - Suggest: "Fix the issues in the export pipeline, then re-run `/bmad-validate-exports`." | ||
| **Exit 2 — Usage error:** | ||
| - Report: "Could not validate — the path may not exist or may not be a directory." |
| --- | ||
| name: "team factory" | ||
| description: "Team Factory - Guided creation of BMAD-compliant teams, agents, and skills" | ||
| --- | ||
| You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. | ||
| ```xml | ||
| <agent id="team-factory.agent.yaml" name="Loom Master" title="Team Factory" icon="🏭"> | ||
| <activation critical="MANDATORY"> | ||
| <step n="1">Load persona from this current agent file (already in context)</step> | ||
| <step n="2">🚨 IMMEDIATE ACTION REQUIRED - BEFORE ANY OUTPUT: | ||
| - Load and read {project-root}/_bmad/bme/_team-factory/config.yaml NOW | ||
| - ERROR HANDLING: If config file not found or cannot be read, IMMEDIATELY display: | ||
| "❌ Configuration Error: Cannot load config file at {project-root}/_bmad/bme/_team-factory/config.yaml | ||
| This file is required for Team Factory to operate. Please verify: | ||
| 1. File exists at the path above | ||
| 2. File has valid YAML syntax | ||
| 3. File contains: user_name, communication_language, output_folder | ||
| If you just installed Team Factory, the config file may be missing. Please reinstall or contact support." | ||
| Then STOP - do NOT proceed to step 3. | ||
| - If config loaded successfully: Store ALL fields as session variables: {user_name}, {communication_language}, {output_folder} | ||
| - VERIFY all 3 required fields are present. If any missing, display: | ||
| "❌ Configuration Error: Missing required field(s) in config.yaml | ||
| Required fields: user_name, communication_language, output_folder | ||
| Found: [list only fields that were found] | ||
| Please update {project-root}/_bmad/bme/_team-factory/config.yaml with all required fields." | ||
| Then STOP - do NOT proceed to step 3. | ||
| - DO NOT PROCEED to step 3 until config is successfully loaded and all variables stored | ||
| </step> | ||
| <step n="3">Remember: user's name is {user_name}</step> | ||
| <step n="4">Show greeting using {user_name} from config, communicate in {communication_language}, then display numbered list of ALL menu items from menu section</step> | ||
| <step n="{HELP_STEP}">Let {user_name} know they can type command `/bmad-help` at any time to get advice on what to do next, and that they can combine that with what they need help with <example>`/bmad-help I want to create a new BMAD team`</example></step> | ||
| <step n="5">STOP and WAIT for user input - do NOT execute menu items automatically - accept number or cmd trigger or fuzzy command match</step> | ||
| <step n="6">On user input: Number → process menu item[n] | Text → case-insensitive substring match | Multiple matches → ask user to clarify | No match → show "Not recognized"</step> | ||
| <step n="7">When processing a menu item: Check menu-handlers section below - extract any attributes from the selected menu item (workflow, exec, tmpl, data, action, validate-workflow) and follow the corresponding handler instructions</step> | ||
| <menu-handlers> | ||
| <handlers> | ||
| <handler type="exec"> | ||
| When menu item or handler has: exec="path/to/file.md": | ||
| 1. CRITICAL: Check if file exists at path | ||
| 2. If file NOT found, IMMEDIATELY display: | ||
| "❌ Workflow Error: Cannot load workflow | ||
| Expected file: {path} | ||
| This workflow is required for Team Factory to operate. | ||
| Possible causes: | ||
| 1. Files missing from installation | ||
| 2. Incorrect path configuration | ||
| 3. Files moved or deleted | ||
| Please verify Team Factory installation or reinstall bme module." | ||
| Then STOP - do NOT proceed | ||
| 3. If file exists: Read fully and follow the file at that path | ||
| 4. Process the complete file and follow all instructions within it | ||
| 5. If there is data="some/path/data-foo.md" with the same item, pass that data path to the executed file as context. | ||
| </handler> | ||
| <handler type="data"> | ||
| When menu item has: data="path/to/file.json|yaml|yml|csv|xml" | ||
| Load the file first, parse according to extension | ||
| Make available as {data} variable to subsequent handler operations | ||
| </handler> | ||
| <handler type="workflow"> | ||
| When menu item has: workflow="path/to/workflow.yaml": | ||
| 1. CRITICAL: Always LOAD {project-root}/_bmad/core/tasks/workflow.xml | ||
| 2. Read the complete file - this is the CORE OS for processing BMAD workflows | ||
| 3. Pass the yaml path as 'workflow-config' parameter to those instructions | ||
| 4. Follow workflow.xml instructions precisely following all steps | ||
| 5. Save outputs after completing EACH workflow step (never batch multiple steps together) | ||
| 6. If workflow.yaml path is "todo", inform user the workflow hasn't been implemented yet | ||
| </handler> | ||
| </handlers> | ||
| </menu-handlers> | ||
| <rules> | ||
| <r>ALWAYS communicate in {communication_language} UNLESS contradicted by communication_style.</r> | ||
| <r>Stay in character until exit selected</r> | ||
| <r>Display Menu items as the item dictates and in the order given.</r> | ||
| <r>Load files ONLY when executing a user chosen workflow or a command requires it, EXCEPTION: agent activation step 2 config.yaml</r> | ||
| <r>Every factory output must be validated before write — this is the governing principle.</r> | ||
| <r>Present decisions one at a time. Never overwhelm — max 3 new concepts per step (NFR2).</r> | ||
| <r>Always explain WHY a decision matters before asking the contributor to choose.</r> | ||
| <r>For Sequential teams, contracts are non-negotiable. For Independent teams, contracts are eliminated from the flow.</r> | ||
| </rules> | ||
| </activation> | ||
| <persona> | ||
| <role>Team Architecture Specialist + BMAD Compliance Expert</role> | ||
| <identity>Master team architect who guides framework contributors through creating fully-wired, BMAD-compliant teams. Specializes in architectural thinking before artifact generation — ensures every team creation goes through structured discovery before any file is produced. | ||
| Core expertise: | ||
| - Composition pattern selection (Independent vs Sequential) | ||
| - Agent scope definition and overlap detection | ||
| - Contract design and pipeline orchestration | ||
| - Integration wiring (registry, config, manifest, activation) | ||
| - Naming convention enforcement | ||
| - End-to-end validation | ||
| Philosophy: "The quality of a BMAD team isn't in the files — it's in the thinking that precedes them." Every team creation is a discovery process, not just file generation.</identity> | ||
| <communication_style>Methodical yet encouraging — like a senior architect pair-programming with a colleague. Asks focused questions, explains trade-offs clearly, and celebrates good decisions. Uses concrete examples from Vortex and Gyre to illustrate patterns. Never dumps all decisions at once — progressive disclosure, one step at a time.</communication_style> | ||
| <principles>- Thinking before files — every team creation goes through discovery before generation - BMAD compliance is non-negotiable — output must be indistinguishable from native teams - No orphaned artifacts — if a file is created, it must be registered, wired, and discoverable - Delegate to BMB for artifact generation — factory owns integration wiring only - Validate continuously — don't wait until the end to check</principles> | ||
| </persona> | ||
| <menu> | ||
| <item cmd="MH or fuzzy match on menu or help">[MH] Redisplay Menu Help</item> | ||
| <item cmd="CH or fuzzy match on chat">[CH] Chat about team architecture, composition patterns, or BMAD compliance</item> | ||
| <item cmd="CT or fuzzy match on create-team or new-team or add-team" exec="{project-root}/_bmad/bme/_team-factory/workflows/step-00-route.md">[CT] Create Team: Build a new BMAD-compliant team from scratch</item> | ||
| <item cmd="RS or fuzzy match on resume" exec="{project-root}/_bmad/bme/_team-factory/workflows/step-00-route.md" data="resume">[RS] Resume: Continue a previously started team creation</item> | ||
| <item cmd="EX or fuzzy match on express" exec="{project-root}/_bmad/bme/_team-factory/workflows/step-00-route.md" data="express">[EX] Express Mode: Create team from an existing spec file</item> | ||
| <item cmd="VT or fuzzy match on validate-team or check-team" exec="{project-root}/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md">[VT] Validate Team: Run end-to-end validation on an existing team</item> | ||
| <item cmd="AR or fuzzy match on architecture-reference or reference" data="{project-root}/_bmad-output/planning-artifacts/architecture-reference-teams.md">[AR] Architecture Reference: Browse the team validity blueprint</item> | ||
| <item cmd="PM or fuzzy match on party-mode" exec="{project-root}/_bmad/core/workflows/party-mode/workflow.md">[PM] Start Party Mode</item> | ||
| <item cmd="DA or fuzzy match on exit, leave, goodbye or dismiss agent">[DA] Dismiss Agent</item> | ||
| </menu> | ||
| </agent> | ||
| ``` |
| submodule_name: _team-factory | ||
| description: Team Factory - Guided creation of BMAD-compliant teams through architectural discovery and automated wiring | ||
| module: bme | ||
| output_folder: '{project-root}/_bmad-output/planning-artifacts' | ||
| agents: | ||
| - team-factory | ||
| workflows: | ||
| - add-team | ||
| version: 1.0.0 | ||
| user_name: '{user}' | ||
| communication_language: en | ||
| party_mode_enabled: true | ||
| core_module: bme |
| 'use strict'; | ||
| /** | ||
| * Pattern-aware decision elimination for the Team Factory. | ||
| * A6'-coupled — adding a third composition pattern = adding a third column to DECISION_CATALOGUE. | ||
| * | ||
| * @module cascade-logic | ||
| */ | ||
| const KNOWN_PATTERNS = ['Independent', 'Sequential']; | ||
| /** | ||
| * Master catalogue of factory decisions with per-pattern relevance. | ||
| * Each entry defines whether the decision is active for a given pattern | ||
| * and whether it's required or optional. | ||
| * | ||
| * @type {Array<{id: string, step: string, description: string, patterns: Object}>} | ||
| */ | ||
| const DECISION_CATALOGUE = [ | ||
| { | ||
| id: 'agent-scope', | ||
| step: 'scope', | ||
| description: 'Define agent roles and capabilities', | ||
| patterns: { | ||
| Independent: { active: true, required: true }, | ||
| Sequential: { active: true, required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'pipeline-order', | ||
| step: 'scope', | ||
| description: 'Define agent execution sequence (pipeline positions)', | ||
| patterns: { | ||
| Independent: { active: false, required: false }, | ||
| Sequential: { active: true, required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'handoff-contracts', | ||
| step: 'connect', | ||
| description: 'Design inter-agent handoff contracts', | ||
| patterns: { | ||
| Independent: { active: false, required: false }, | ||
| Sequential: { active: true, required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'feedback-contracts', | ||
| step: 'connect', | ||
| description: 'Design feedback routing contracts (downstream → upstream)', | ||
| patterns: { | ||
| Independent: { active: false, required: false }, | ||
| Sequential: { active: true, required: false }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'contract-prefix', | ||
| step: 'connect', | ||
| description: 'Define naming prefix for contracts (e.g., HC, GC)', | ||
| patterns: { | ||
| Independent: { active: false, required: false }, | ||
| Sequential: { active: true, required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'compass-routing', | ||
| step: 'connect', | ||
| description: 'Create compass routing reference for workflow navigation', | ||
| patterns: { | ||
| Independent: { active: true, required: false, defaultValue: 'per-agent' }, | ||
| Sequential: { active: true, required: true, defaultValue: 'shared-reference' }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'orchestration-workflow', | ||
| step: 'connect', | ||
| description: 'Create pipeline orchestration workflow', | ||
| patterns: { | ||
| Independent: { active: false, required: false }, | ||
| Sequential: { active: true, required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'output-directory', | ||
| step: 'connect', | ||
| description: 'Define artifact output location', | ||
| patterns: { | ||
| Independent: { active: true, required: true, defaultValue: '_bmad-output/{team}-artifacts' }, | ||
| Sequential: { active: true, required: true, defaultValue: '_bmad-output/{team}-artifacts' }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'naming-enforcement', | ||
| step: 'scope', | ||
| description: 'Validate all naming conventions (agent IDs, file names, module directory)', | ||
| patterns: { | ||
| Independent: { active: true, required: true }, | ||
| Sequential: { active: true, required: true }, | ||
| }, | ||
| }, | ||
| { | ||
| id: 'overlap-detection', | ||
| step: 'scope', | ||
| description: 'Check proposed agents against existing agent manifest for collisions', | ||
| patterns: { | ||
| Independent: { active: true, required: true }, | ||
| Sequential: { active: true, required: true }, | ||
| }, | ||
| }, | ||
| ]; | ||
| /** | ||
| * Given a composition pattern, return which factory decisions are relevant | ||
| * and which are eliminated. | ||
| * | ||
| * @param {string} pattern - "Independent" or "Sequential" | ||
| * @returns {{ decisions: CascadeDecision[], eliminated: CascadeDecision[], error?: string }} | ||
| */ | ||
| function getCascadeForPattern(pattern) { | ||
| const validation = validatePattern(pattern); | ||
| if (!validation.valid) { | ||
| return { decisions: [], eliminated: [], error: validation.error }; | ||
| } | ||
| const decisions = []; | ||
| const eliminated = []; | ||
| for (const entry of DECISION_CATALOGUE) { | ||
| const patternConfig = entry.patterns[pattern]; | ||
| const decision = { | ||
| id: entry.id, | ||
| step: entry.step, | ||
| description: entry.description, | ||
| required: patternConfig.required, | ||
| defaultValue: patternConfig.defaultValue || undefined, | ||
| }; | ||
| if (patternConfig.active) { | ||
| decisions.push(decision); | ||
| } else { | ||
| eliminated.push(decision); | ||
| } | ||
| } | ||
| return { decisions, eliminated }; | ||
| } | ||
| /** | ||
| * Validate that a pattern string is recognized. | ||
| * @param {string} pattern | ||
| * @returns {{ valid: boolean, error?: string }} | ||
| */ | ||
| function validatePattern(pattern) { | ||
| if (!pattern || typeof pattern !== 'string') { | ||
| return { valid: false, error: 'Pattern must be a non-empty string' }; | ||
| } | ||
| if (!KNOWN_PATTERNS.includes(pattern)) { | ||
| return { valid: false, error: `Unknown pattern "${pattern}". Expected one of: ${KNOWN_PATTERNS.join(', ')}` }; | ||
| } | ||
| return { valid: true }; | ||
| } | ||
| /** | ||
| * Get the list of known composition patterns. | ||
| * @returns {string[]} | ||
| */ | ||
| function getKnownPatterns() { | ||
| return [...KNOWN_PATTERNS]; | ||
| } | ||
| /** | ||
| * @typedef {Object} CascadeDecision | ||
| * @property {string} id - Decision identifier | ||
| * @property {string} step - Factory step this belongs to (scope, connect, generate, validate) | ||
| * @property {string} description - Human-readable description | ||
| * @property {boolean} required - Whether mandatory for the pattern | ||
| * @property {string} [defaultValue] - Default if applicable | ||
| */ | ||
| module.exports = { | ||
| getCascadeForPattern, | ||
| validatePattern, | ||
| getKnownPatterns, | ||
| }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| /** | ||
| * Detect collisions between a new team spec and existing framework state. | ||
| * Handles Level 1 (exact ID match) and Level 2 (name similarity). | ||
| * Level 3 (capability overlap) is LLM reasoning in the workflow step. | ||
| * | ||
| * @param {Object} specData - Parsed team spec | ||
| * @param {string} manifestPath - Absolute path to agent-manifest.csv | ||
| * @param {string} [bmeDir] - Path to _bmad/bme/ directory for submodule name checks | ||
| * @returns {Promise<CollisionResult>} | ||
| */ | ||
| async function detectCollisions(specData, manifestPath, bmeDir) { | ||
| const blocks = []; | ||
| const warnings = []; | ||
| const dataWarnings = []; | ||
| // Parse existing agent manifest | ||
| const { agents: existingAgents, warning: manifestWarning } = await parseManifest(manifestPath); | ||
| if (manifestWarning) dataWarnings.push(manifestWarning); | ||
| // Parse existing submodule directories | ||
| const { modules: existingModules, warning: modulesWarning } = bmeDir | ||
| ? await listSubmodules(bmeDir) | ||
| : { modules: [], warning: null }; | ||
| if (modulesWarning) dataWarnings.push(modulesWarning); | ||
| const proposedTeam = specData.team_name_kebab || ''; | ||
| const proposedAgents = (specData.agents || []).map(a => a.id).filter(Boolean); | ||
| // --- Level 1: Exact submodule name collision --- | ||
| if (proposedTeam && existingModules.includes(`_${proposedTeam}`)) { | ||
| blocks.push({ | ||
| level: 'exact', | ||
| field: 'submodule_name', | ||
| newValue: `_${proposedTeam}`, | ||
| existingValue: `_${proposedTeam}`, | ||
| existingModule: proposedTeam, | ||
| suggestion: `Module directory _bmad/bme/_${proposedTeam}/ already exists. Choose a different team name.`, | ||
| }); | ||
| } | ||
| // --- Level 1: Exact agent ID collision --- | ||
| for (const agentId of proposedAgents) { | ||
| const match = existingAgents.find(a => a.id === agentId); | ||
| if (match) { | ||
| blocks.push({ | ||
| level: 'exact', | ||
| field: 'agent_id', | ||
| newValue: agentId, | ||
| existingValue: match.id, | ||
| existingModule: match.module, | ||
| suggestion: `Agent ID "${agentId}" already exists in module "${match.module}". Rename your agent.`, | ||
| }); | ||
| } | ||
| } | ||
| // --- Level 2: Similar agent name detection --- | ||
| for (const agentId of proposedAgents) { | ||
| for (const existing of existingAgents) { | ||
| if (existing.id === agentId) continue; // Already caught by Level 1 | ||
| const dist = levenshtein(agentId, existing.id); | ||
| const sharePrefix = sharedPrefix(agentId, existing.id); | ||
| if (dist <= 2 || (sharePrefix >= 4 && dist <= 3)) { | ||
| warnings.push({ | ||
| level: 'similar', | ||
| field: 'agent_id', | ||
| newValue: agentId, | ||
| existingValue: existing.id, | ||
| existingModule: existing.module, | ||
| suggestion: `"${agentId}" is similar to existing "${existing.id}" (${existing.module}). Intentional?`, | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return { | ||
| blocks, | ||
| warnings, | ||
| dataWarnings, | ||
| hasBlocking: blocks.length > 0, | ||
| }; | ||
| } | ||
| /** | ||
| * Parse agent-manifest.csv to extract agent IDs and their modules. | ||
| * @param {string} manifestPath | ||
| * @returns {Promise<{agents: Array<{id: string, module: string}>, warning: string|null}>} | ||
| */ | ||
| async function parseManifest(manifestPath) { | ||
| try { | ||
| const content = await fs.readFile(manifestPath, 'utf8'); | ||
| const lines = content.split('\n').filter(l => l.trim()); | ||
| if (lines.length < 2) return { agents: [], warning: null }; | ||
| const results = []; | ||
| // Skip header (line 0), parse data rows | ||
| for (let i = 1; i < lines.length; i++) { | ||
| const fields = parseCSVLine(lines[i]); | ||
| if (fields.length >= 10) { | ||
| const id = fields[0].replace(/^"|"$/g, '').trim(); | ||
| const module = fields[9].replace(/^"|"$/g, '').trim(); | ||
| if (id) results.push({ id, module }); | ||
| } | ||
| } | ||
| return { agents: results, warning: null }; | ||
| } catch (err) { | ||
| return { agents: [], warning: `Could not read agent manifest at ${manifestPath}: ${err.message}. Collision detection may be incomplete.` }; | ||
| } | ||
| } | ||
| /** | ||
| * Parse a single CSV line handling quoted fields. | ||
| * @param {string} line | ||
| * @returns {string[]} | ||
| */ | ||
| function parseCSVLine(line) { | ||
| const fields = []; | ||
| let current = ''; | ||
| let inQuotes = false; | ||
| for (let i = 0; i < line.length; i++) { | ||
| const ch = line[i]; | ||
| if (inQuotes) { | ||
| if (ch === '"' && line[i + 1] === '"') { | ||
| current += '"'; | ||
| i++; // skip escaped quote | ||
| } else if (ch === '"') { | ||
| inQuotes = false; | ||
| } else { | ||
| current += ch; | ||
| } | ||
| } else { | ||
| if (ch === '"') { | ||
| inQuotes = true; | ||
| } else if (ch === ',') { | ||
| fields.push(current); | ||
| current = ''; | ||
| } else { | ||
| current += ch; | ||
| } | ||
| } | ||
| } | ||
| fields.push(current); | ||
| return fields; | ||
| } | ||
| /** | ||
| * List existing submodule directory names under _bmad/bme/. | ||
| * @param {string} bmeDir | ||
| * @returns {Promise<{modules: string[], warning: string|null}>} | ||
| */ | ||
| async function listSubmodules(bmeDir) { | ||
| try { | ||
| const entries = await fs.readdir(bmeDir, { withFileTypes: true }); | ||
| const modules = entries | ||
| .filter(e => e.isDirectory() && e.name.startsWith('_')) | ||
| .map(e => e.name); | ||
| return { modules, warning: null }; | ||
| } catch (err) { | ||
| return { modules: [], warning: `Could not read bme directory at ${bmeDir}: ${err.message}. Submodule collision detection skipped.` }; | ||
| } | ||
| } | ||
| /** | ||
| * Compute Levenshtein edit distance between two strings. | ||
| * @param {string} a | ||
| * @param {string} b | ||
| * @returns {number} | ||
| */ | ||
| function levenshtein(a, b) { | ||
| if (a.length === 0) return b.length; | ||
| if (b.length === 0) return a.length; | ||
| const matrix = Array.from({ length: b.length + 1 }, (_, i) => [i]); | ||
| for (let j = 0; j <= a.length; j++) matrix[0][j] = j; | ||
| for (let i = 1; i <= b.length; i++) { | ||
| for (let j = 1; j <= a.length; j++) { | ||
| const cost = a[j - 1] === b[i - 1] ? 0 : 1; | ||
| matrix[i][j] = Math.min( | ||
| matrix[i - 1][j] + 1, | ||
| matrix[i][j - 1] + 1, | ||
| matrix[i - 1][j - 1] + cost, | ||
| ); | ||
| } | ||
| } | ||
| return matrix[b.length][a.length]; | ||
| } | ||
| /** | ||
| * Count shared prefix length between two strings. | ||
| * @param {string} a | ||
| * @param {string} b | ||
| * @returns {number} | ||
| */ | ||
| function sharedPrefix(a, b) { | ||
| let i = 0; | ||
| while (i < a.length && i < b.length && a[i] === b[i]) i++; | ||
| return i; | ||
| } | ||
| /** | ||
| * @typedef {Object} CollisionResult | ||
| * @property {CollisionEntry[]} blocks - Level 1: exact matches, must be resolved | ||
| * @property {CollisionEntry[]} warnings - Level 2: similar names, review recommended | ||
| * @property {boolean} hasBlocking - true if any blocks exist | ||
| */ | ||
| /** | ||
| * @typedef {Object} CollisionEntry | ||
| * @property {string} level - "exact" or "similar" | ||
| * @property {string} field - "agent_id", "submodule_name", or "workflow_name" | ||
| * @property {string} newValue | ||
| * @property {string} existingValue | ||
| * @property {string} existingModule | ||
| * @property {string} suggestion | ||
| */ | ||
| module.exports = { | ||
| detectCollisions, | ||
| }; |
| 'use strict'; | ||
| const path = require('path'); | ||
| /** @typedef {import('./types/factory-types').ManifestEntry} ManifestEntry */ | ||
| /** | ||
| * Build a file manifest from spec data and generation context. | ||
| * The manifest is built from tracked generation variables — NOT filesystem scanning. | ||
| * | ||
| * @param {Object} specData - Parsed team spec (needs team_name_kebab, spec_file_path) | ||
| * @param {Object} generationContext - Context from Step 4 | ||
| * @param {string[]} generationContext.agent_files - Agent .md file paths | ||
| * @param {string[]} generationContext.workflow_dirs - Workflow directory paths | ||
| * @param {string[]} [generationContext.contract_files] - Contract .md file paths | ||
| * @param {string} generationContext.config_yaml_path - Path to config.yaml | ||
| * @param {string} generationContext.module_help_csv_path - Path to module-help.csv | ||
| * @param {string} generationContext.module_root - Module root directory | ||
| * @returns {ManifestEntry[]} | ||
| */ | ||
| function buildManifest(specData, generationContext) { | ||
| const entries = []; | ||
| const moduleName = specData.team_name_kebab || 'unknown'; | ||
| // Agent files — created | ||
| for (const agentFile of (generationContext.agent_files || [])) { | ||
| entries.push({ path: agentFile, operation: 'created', module: moduleName }); | ||
| } | ||
| // Workflow directories — each gets a workflow.md and SKILL.md | ||
| for (const wfDir of (generationContext.workflow_dirs || [])) { | ||
| entries.push({ path: path.join(wfDir, 'workflow.md'), operation: 'created', module: moduleName }); | ||
| entries.push({ path: path.join(wfDir, 'SKILL.md'), operation: 'created', module: moduleName }); | ||
| } | ||
| // Contract files — created | ||
| for (const contractFile of (generationContext.contract_files || [])) { | ||
| entries.push({ path: contractFile, operation: 'created', module: moduleName }); | ||
| } | ||
| // Compass routing reference — created (if exists in generated_files) | ||
| const compassFile = path.join(generationContext.module_root || '', 'compass-routing-reference.md'); | ||
| const generatedFiles = generationContext.generated_files || []; | ||
| if (generatedFiles.includes(compassFile)) { | ||
| entries.push({ path: compassFile, operation: 'created', module: moduleName }); | ||
| } | ||
| // Config.yaml — created | ||
| if (generationContext.config_yaml_path) { | ||
| entries.push({ path: generationContext.config_yaml_path, operation: 'created', module: moduleName }); | ||
| } | ||
| // Module-help.csv — created | ||
| if (generationContext.module_help_csv_path) { | ||
| entries.push({ path: generationContext.module_help_csv_path, operation: 'created', module: moduleName }); | ||
| } | ||
| // agent-registry.js — modified | ||
| entries.push({ path: 'scripts/update/lib/agent-registry.js', operation: 'modified', module: moduleName }); | ||
| // Spec file — modified (progress updated) | ||
| if (specData.spec_file_path) { | ||
| entries.push({ path: specData.spec_file_path, operation: 'modified', module: moduleName }); | ||
| } | ||
| return entries; | ||
| } | ||
| /** | ||
| * Format a manifest as a human-readable markdown table. | ||
| * @param {ManifestEntry[]} entries | ||
| * @returns {string} | ||
| */ | ||
| function formatManifest(entries) { | ||
| const lines = []; | ||
| lines.push('| # | Path | Operation | Module |'); | ||
| lines.push('|---|------|-----------|--------|'); | ||
| entries.forEach((entry, i) => { | ||
| lines.push(`| ${i + 1} | \`${entry.path}\` | ${entry.operation} | ${entry.module} |`); | ||
| }); | ||
| return lines.join('\n'); | ||
| } | ||
| /** | ||
| * Format abort/removal instructions from a manifest. | ||
| * Created files get `rm`, modified files get `git checkout --`. | ||
| * @param {ManifestEntry[]} entries | ||
| * @returns {string} | ||
| */ | ||
| function formatAbortInstructions(entries) { | ||
| const lines = []; | ||
| lines.push('# Removal instructions'); | ||
| lines.push(''); | ||
| const created = entries.filter(e => e.operation === 'created'); | ||
| const modified = entries.filter(e => e.operation === 'modified'); | ||
| if (created.length > 0) { | ||
| lines.push('# Created files — remove:'); | ||
| for (const entry of created) { | ||
| lines.push(`rm "${entry.path}"`); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| if (modified.length > 0) { | ||
| lines.push('# Modified files — revert:'); | ||
| for (const entry of modified) { | ||
| lines.push(`git checkout -- "${entry.path}"`); | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| /** | ||
| * Build a file manifest for an extension operation (add agent to existing team). | ||
| * New agent/workflow/contract files are "created"; config, CSV, and registry are "modified". | ||
| * | ||
| * @param {Object} extensionContext | ||
| * @param {string} extensionContext.new_agent_id - New agent ID (for module label) | ||
| * @param {string[]} extensionContext.new_agent_files - New agent .md file paths | ||
| * @param {string[]} extensionContext.new_workflow_dirs - New workflow directory paths | ||
| * @param {string[]} [extensionContext.new_contract_files] - New contract file paths | ||
| * @param {string} extensionContext.config_yaml_path - Path to config.yaml (modified) | ||
| * @param {string} extensionContext.module_help_csv_path - Path to module-help.csv (modified) | ||
| * @returns {ManifestEntry[]} | ||
| */ | ||
| function buildExtensionManifest(extensionContext) { | ||
| const entries = []; | ||
| const moduleName = extensionContext.new_agent_id || 'unknown-agent'; | ||
| // New agent files — created | ||
| for (const agentFile of (extensionContext.new_agent_files || [])) { | ||
| entries.push({ path: agentFile, operation: 'created', module: moduleName }); | ||
| } | ||
| // New workflow directories — each gets workflow.md and SKILL.md | ||
| for (const wfDir of (extensionContext.new_workflow_dirs || [])) { | ||
| entries.push({ path: path.join(wfDir, 'workflow.md'), operation: 'created', module: moduleName }); | ||
| entries.push({ path: path.join(wfDir, 'SKILL.md'), operation: 'created', module: moduleName }); | ||
| } | ||
| // New contract files — created | ||
| for (const contractFile of (extensionContext.new_contract_files || [])) { | ||
| entries.push({ path: contractFile, operation: 'created', module: moduleName }); | ||
| } | ||
| // Config.yaml — modified (agent appended) | ||
| if (extensionContext.config_yaml_path) { | ||
| entries.push({ path: extensionContext.config_yaml_path, operation: 'modified', module: moduleName }); | ||
| } | ||
| // Module-help.csv — modified (row appended) | ||
| if (extensionContext.module_help_csv_path) { | ||
| entries.push({ path: extensionContext.module_help_csv_path, operation: 'modified', module: moduleName }); | ||
| } | ||
| // agent-registry.js — modified (agent appended to existing block) | ||
| entries.push({ path: 'scripts/update/lib/agent-registry.js', operation: 'modified', module: moduleName }); | ||
| return entries; | ||
| } | ||
| /** | ||
| * Build a file manifest for a skill/workflow extension (add workflow to existing agent). | ||
| * New workflow files are "created"; agent .md, config, CSV, and registry are "modified". | ||
| * | ||
| * @param {Object} skillContext | ||
| * @param {string} skillContext.new_workflow_name - New workflow name (for module label) | ||
| * @param {string} skillContext.agent_id - Target agent ID | ||
| * @param {string[]} skillContext.new_workflow_files - New workflow file paths (workflow.md, template) | ||
| * @param {string} [skillContext.agent_file_path] - Agent .md file path (modified for menu) | ||
| * @param {string} skillContext.config_yaml_path - Path to config.yaml (modified) | ||
| * @param {string} skillContext.module_help_csv_path - Path to module-help.csv (modified) | ||
| * @returns {ManifestEntry[]} | ||
| */ | ||
| function buildSkillExtensionManifest(skillContext) { | ||
| const entries = []; | ||
| const moduleName = skillContext.new_workflow_name || 'unknown-workflow'; | ||
| // New workflow files — created | ||
| for (const wfFile of (skillContext.new_workflow_files || [])) { | ||
| entries.push({ path: wfFile, operation: 'created', module: moduleName }); | ||
| } | ||
| // Agent .md file — modified (menu item added) | ||
| if (skillContext.agent_file_path) { | ||
| entries.push({ path: skillContext.agent_file_path, operation: 'modified', module: moduleName }); | ||
| } | ||
| // Config.yaml — modified (workflow appended) | ||
| if (skillContext.config_yaml_path) { | ||
| entries.push({ path: skillContext.config_yaml_path, operation: 'modified', module: moduleName }); | ||
| } | ||
| // Module-help.csv — modified (row appended) | ||
| if (skillContext.module_help_csv_path) { | ||
| entries.push({ path: skillContext.module_help_csv_path, operation: 'modified', module: moduleName }); | ||
| } | ||
| // agent-registry.js — modified (workflow appended to existing block) | ||
| entries.push({ path: 'scripts/update/lib/agent-registry.js', operation: 'modified', module: moduleName }); | ||
| return entries; | ||
| } | ||
| module.exports = { | ||
| buildManifest, | ||
| buildExtensionManifest, | ||
| buildSkillExtensionManifest, | ||
| formatManifest, | ||
| formatAbortInstructions, | ||
| }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const yaml = require('js-yaml'); | ||
| /** @typedef {import('./types/factory-types').TeamSpec} TeamSpec */ | ||
| /** | ||
| * Step completion states. | ||
| * @type {Object<string, string>} | ||
| */ | ||
| const STEP_ORDER = ['orient', 'scope', 'connect', 'review', 'generate', 'validate']; | ||
| /** | ||
| * Determine the resume point for a spec file by reading its progress section. | ||
| * Returns the first step that is not 'complete'. | ||
| * | ||
| * For the 'generate' step, also checks per-agent completion status | ||
| * and returns which agents still need generation. | ||
| * | ||
| * @param {string} specPath - Absolute path to the spec file | ||
| * @returns {Promise<ResumeResult>} | ||
| */ | ||
| async function findResumePoint(specPath) { | ||
| let raw; | ||
| try { | ||
| raw = await fs.readFile(specPath, 'utf8'); | ||
| } catch (err) { | ||
| return { resumable: false, resumeStep: null, pendingAgents: [], errors: [`Cannot read spec file: ${err.message}`] }; | ||
| } | ||
| let doc; | ||
| try { | ||
| doc = yaml.load(raw); | ||
| } catch (err) { | ||
| return { resumable: false, resumeStep: null, pendingAgents: [], errors: [`Invalid YAML: ${err.message}`] }; | ||
| } | ||
| if (!doc || !doc.progress) { | ||
| return { resumable: false, resumeStep: null, pendingAgents: [], errors: ['Spec file has no progress section'] }; | ||
| } | ||
| const progress = doc.progress; | ||
| // Find first non-complete step | ||
| for (const step of STEP_ORDER) { | ||
| const status = typeof progress[step] === 'string' ? progress[step] : null; | ||
| if (step === 'generate' && typeof progress[step] === 'object') { | ||
| // Generate step has per-agent tracking | ||
| const pending = []; | ||
| for (const [agentId, agentStatus] of Object.entries(progress[step])) { | ||
| if (agentStatus !== 'complete') { | ||
| pending.push(agentId); | ||
| } | ||
| } | ||
| if (pending.length > 0) { | ||
| return { resumable: true, resumeStep: 'generate', pendingAgents: pending, errors: [] }; | ||
| } | ||
| // All agents complete — continue to next step | ||
| continue; | ||
| } | ||
| if (status !== 'complete') { | ||
| return { resumable: true, resumeStep: step, pendingAgents: [], errors: [] }; | ||
| } | ||
| } | ||
| // All steps complete | ||
| return { resumable: false, resumeStep: null, pendingAgents: [], errors: [], allComplete: true }; | ||
| } | ||
| /** | ||
| * Compare two spec objects and return which fields changed. | ||
| * Used for displaying diffs when resuming or in express mode. | ||
| * | ||
| * @param {TeamSpec} oldSpec - Previous spec state | ||
| * @param {TeamSpec} newSpec - Current spec state | ||
| * @returns {SpecDiff} | ||
| */ | ||
| function diffSpecs(oldSpec, newSpec) { | ||
| const changes = []; | ||
| // Compare top-level scalar fields | ||
| const scalarFields = ['team_name', 'team_name_kebab', 'description', 'composition_pattern', 'factory_version']; | ||
| for (const field of scalarFields) { | ||
| if (oldSpec[field] !== newSpec[field]) { | ||
| changes.push({ field, oldValue: oldSpec[field], newValue: newSpec[field] }); | ||
| } | ||
| } | ||
| // Compare agent count | ||
| const oldAgentCount = (oldSpec.agents || []).length; | ||
| const newAgentCount = (newSpec.agents || []).length; | ||
| if (oldAgentCount !== newAgentCount) { | ||
| changes.push({ field: 'agents.length', oldValue: String(oldAgentCount), newValue: String(newAgentCount) }); | ||
| } | ||
| // Compare agent IDs | ||
| const oldIds = (oldSpec.agents || []).map(a => a.id).sort(); | ||
| const newIds = (newSpec.agents || []).map(a => a.id).sort(); | ||
| const addedAgents = newIds.filter(id => !oldIds.includes(id)); | ||
| const removedAgents = oldIds.filter(id => !newIds.includes(id)); | ||
| if (addedAgents.length > 0) { | ||
| changes.push({ field: 'agents.added', oldValue: '', newValue: addedAgents.join(', ') }); | ||
| } | ||
| if (removedAgents.length > 0) { | ||
| changes.push({ field: 'agents.removed', oldValue: removedAgents.join(', '), newValue: '' }); | ||
| } | ||
| // Compare contract count (Sequential only) | ||
| const oldContracts = (oldSpec.contracts || []).length; | ||
| const newContracts = (newSpec.contracts || []).length; | ||
| if (oldContracts !== newContracts) { | ||
| changes.push({ field: 'contracts.length', oldValue: String(oldContracts), newValue: String(newContracts) }); | ||
| } | ||
| // Compare progress | ||
| for (const step of STEP_ORDER) { | ||
| const oldStatus = typeof oldSpec.progress?.[step] === 'string' ? oldSpec.progress[step] : stableStringify(oldSpec.progress?.[step]); | ||
| const newStatus = typeof newSpec.progress?.[step] === 'string' ? newSpec.progress[step] : stableStringify(newSpec.progress?.[step]); | ||
| if (oldStatus !== newStatus) { | ||
| changes.push({ field: `progress.${step}`, oldValue: oldStatus || 'undefined', newValue: newStatus || 'undefined' }); | ||
| } | ||
| } | ||
| return { | ||
| hasChanges: changes.length > 0, | ||
| changeCount: changes.length, | ||
| changes, | ||
| }; | ||
| } | ||
| /** | ||
| * JSON.stringify with sorted keys for order-independent comparison. | ||
| * @param {*} obj | ||
| * @returns {string} | ||
| */ | ||
| function stableStringify(obj) { | ||
| if (obj === null || obj === undefined) return String(obj); | ||
| if (typeof obj !== 'object') return JSON.stringify(obj); | ||
| const sorted = Object.keys(obj).sort().reduce((acc, key) => { | ||
| acc[key] = obj[key]; | ||
| return acc; | ||
| }, {}); | ||
| return JSON.stringify(sorted); | ||
| } | ||
| /** | ||
| * @typedef {Object} ResumeResult | ||
| * @property {boolean} resumable - True if there's a step to resume from | ||
| * @property {string|null} resumeStep - The step to resume from | ||
| * @property {string[]} pendingAgents - For generate step: agents still pending | ||
| * @property {string[]} errors | ||
| * @property {boolean} [allComplete] - True if all steps are complete | ||
| */ | ||
| /** | ||
| * @typedef {Object} SpecDiff | ||
| * @property {boolean} hasChanges | ||
| * @property {number} changeCount | ||
| * @property {SpecChange[]} changes | ||
| */ | ||
| /** | ||
| * @typedef {Object} SpecChange | ||
| * @property {string} field - Dot-path to the changed field | ||
| * @property {string} oldValue | ||
| * @property {string} newValue | ||
| */ | ||
| module.exports = { | ||
| findResumePoint, | ||
| diffSpecs, | ||
| STEP_ORDER, | ||
| }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const yaml = require('js-yaml'); | ||
| /** @typedef {import('./types/factory-types').TeamSpec} TeamSpec */ | ||
| const SCHEMAS_DIR = path.join(__dirname, '..', 'schemas'); | ||
| /** | ||
| * Load and validate a team spec YAML file. | ||
| * Selects schema by composition_pattern, validates required fields and naming. | ||
| * | ||
| * @param {string} specPath - Absolute path to team spec YAML file | ||
| * @returns {Promise<{ valid: boolean, spec: TeamSpec|null, errors: string[] }>} | ||
| */ | ||
| async function parseSpec(specPath) { | ||
| let raw; | ||
| try { | ||
| raw = await fs.readFile(specPath, 'utf8'); | ||
| } catch (err) { | ||
| return { valid: false, spec: null, errors: [`Cannot read spec file: ${err.message}`] }; | ||
| } | ||
| let doc; | ||
| try { | ||
| doc = yaml.load(raw); | ||
| } catch (err) { | ||
| return { valid: false, spec: null, errors: [`Invalid YAML: ${err.message}`] }; | ||
| } | ||
| if (!doc || typeof doc !== 'object') { | ||
| return { valid: false, spec: null, errors: ['Spec file is empty or not an object'] }; | ||
| } | ||
| // Determine pattern and load schema | ||
| const pattern = doc.composition_pattern; | ||
| if (!pattern) { | ||
| return { valid: false, spec: null, errors: ['Missing required field: composition_pattern'] }; | ||
| } | ||
| const schemaFile = pattern === 'Sequential' | ||
| ? 'schema-sequential.json' | ||
| : pattern === 'Independent' | ||
| ? 'schema-independent.json' | ||
| : null; | ||
| if (!schemaFile) { | ||
| return { valid: false, spec: null, errors: [`Unknown composition_pattern: "${pattern}". Expected "Independent" or "Sequential"`] }; | ||
| } | ||
| // Load schema for reference (structural validation below) | ||
| const schemaPath = path.join(SCHEMAS_DIR, schemaFile); | ||
| let schema; | ||
| try { | ||
| schema = JSON.parse(await fs.readFile(schemaPath, 'utf8')); | ||
| } catch (err) { | ||
| return { valid: false, spec: null, errors: [`Cannot load schema ${schemaFile}: ${err.message}`] }; | ||
| } | ||
| // Validate against schema required fields and patterns | ||
| const errors = validateAgainstSchema(doc, schema, pattern); | ||
| if (errors.length > 0) { | ||
| return { valid: false, spec: null, errors }; | ||
| } | ||
| return { valid: true, spec: doc, errors: [] }; | ||
| } | ||
| /** | ||
| * Validate a parsed document against schema required fields, types, and patterns. | ||
| * Lightweight validation without ajv — checks required fields, naming patterns, and structural rules. | ||
| * | ||
| * @param {Object} doc - Parsed YAML document | ||
| * @param {Object} schema - JSON Schema object | ||
| * @param {string} pattern - "Independent" or "Sequential" | ||
| * @returns {string[]} errors | ||
| */ | ||
| function validateAgainstSchema(doc, schema, pattern) { | ||
| const errors = []; | ||
| // Check required root fields | ||
| const required = schema.required || []; | ||
| for (const field of required) { | ||
| if (doc[field] === undefined || doc[field] === null) { | ||
| errors.push(`Missing required field: ${field}`); | ||
| } | ||
| } | ||
| // Type checks on key fields | ||
| if (doc.schema_version !== undefined && typeof doc.schema_version !== 'string') { | ||
| errors.push(`schema_version must be a string, got ${typeof doc.schema_version}`); | ||
| } else if (doc.schema_version && !/^\d+\.\d+$/.test(doc.schema_version)) { | ||
| errors.push(`schema_version "${doc.schema_version}" does not match pattern "N.N" (e.g., "1.0")`); | ||
| } | ||
| if (doc.composition_pattern !== undefined && doc.composition_pattern !== pattern) { | ||
| errors.push(`composition_pattern "${doc.composition_pattern}" does not match expected "${pattern}"`); | ||
| } | ||
| if (doc.team_name !== undefined && (typeof doc.team_name !== 'string' || doc.team_name.length === 0)) { | ||
| errors.push('team_name must be a non-empty string'); | ||
| } | ||
| // Validate naming patterns | ||
| const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/; | ||
| const AGENT_ID_RE = /^[a-z]+(-[a-z]+)*$/; | ||
| if (doc.team_name_kebab && !KEBAB_RE.test(doc.team_name_kebab)) { | ||
| errors.push(`team_name_kebab "${doc.team_name_kebab}" does not match kebab-case pattern: ${KEBAB_RE}`); | ||
| } | ||
| // Validate agents array | ||
| if (Array.isArray(doc.agents)) { | ||
| if (doc.agents.length === 0) { | ||
| errors.push('agents array must contain at least one agent'); | ||
| } | ||
| for (let i = 0; i < doc.agents.length; i++) { | ||
| const agent = doc.agents[i]; | ||
| if (!agent.id) { | ||
| errors.push(`agents[${i}]: missing required field "id"`); | ||
| } else if (!AGENT_ID_RE.test(agent.id)) { | ||
| errors.push(`agents[${i}].id "${agent.id}" does not match agent ID pattern: ${AGENT_ID_RE}`); | ||
| } | ||
| if (!agent.role) { | ||
| errors.push(`agents[${i}]: missing required field "role"`); | ||
| } | ||
| // Sequential-specific: pipeline_position required | ||
| if (pattern === 'Sequential' && agent.pipeline_position === undefined) { | ||
| errors.push(`agents[${i}]: Sequential pattern requires pipeline_position`); | ||
| } | ||
| } | ||
| // Check for duplicate agent IDs | ||
| const ids = doc.agents.map(a => a.id).filter(Boolean); | ||
| const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); | ||
| if (dupes.length > 0) { | ||
| errors.push(`Duplicate agent IDs: ${[...new Set(dupes)].join(', ')}`); | ||
| } | ||
| } else if (required.includes('agents')) { | ||
| errors.push('agents must be an array'); | ||
| } | ||
| // Sequential-specific validations | ||
| if (pattern === 'Sequential') { | ||
| if (!Array.isArray(doc.contracts) || doc.contracts.length === 0) { | ||
| errors.push('Sequential pattern requires at least one contract'); | ||
| } else { | ||
| for (let i = 0; i < doc.contracts.length; i++) { | ||
| const c = doc.contracts[i]; | ||
| if (!c.id) errors.push(`contracts[${i}]: missing required field "id"`); | ||
| if (!c.source_agent) errors.push(`contracts[${i}]: missing required field "source_agent"`); | ||
| if (!Array.isArray(c.target_agents) || c.target_agents.length === 0) { | ||
| errors.push(`contracts[${i}]: target_agents must be a non-empty array`); | ||
| } | ||
| } | ||
| } | ||
| if (!doc.integration?.contract_prefix) { | ||
| errors.push('Sequential pattern requires integration.contract_prefix'); | ||
| } | ||
| } | ||
| // Validate integration block | ||
| if (doc.integration) { | ||
| if (typeof doc.integration !== 'object' || Array.isArray(doc.integration)) { | ||
| errors.push('integration must be an object'); | ||
| } else if (!doc.integration.output_directory) { | ||
| errors.push('integration.output_directory is required'); | ||
| } | ||
| } | ||
| return errors; | ||
| } | ||
| /** | ||
| * Parse a spec from a YAML string (for testing or in-memory use). | ||
| * Pattern is determined from the document's composition_pattern field. | ||
| * | ||
| * @param {string} yamlString - Raw YAML content | ||
| * @returns {Promise<{ valid: boolean, spec: TeamSpec|null, errors: string[] }>} | ||
| */ | ||
| async function parseSpecFromString(yamlString) { | ||
| const tmpDir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'bmad-tf-parse-')); | ||
| const tmpFile = path.join(tmpDir, 'spec.yaml'); | ||
| try { | ||
| await fs.writeFile(tmpFile, yamlString, 'utf8'); | ||
| return await parseSpec(tmpFile); | ||
| } finally { | ||
| await fs.remove(tmpDir); | ||
| } | ||
| } | ||
| module.exports = { | ||
| parseSpec, | ||
| parseSpecFromString, | ||
| validateAgainstSchema, | ||
| }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const yaml = require('js-yaml'); | ||
| /** @typedef {import('./types/factory-types').TeamSpec} TeamSpec */ | ||
| /** | ||
| * Write a team spec to a YAML file with atomic write safety. | ||
| * Protocol: write to .tmp → validate parse → rename to target. | ||
| * | ||
| * @param {TeamSpec} spec - The team spec object to write | ||
| * @param {string} targetPath - Absolute path to the output YAML file | ||
| * @returns {Promise<{ success: boolean, errors: string[] }>} | ||
| */ | ||
| async function writeSpec(spec, targetPath) { | ||
| if (!spec || typeof spec !== 'object') { | ||
| return { success: false, errors: ['spec must be a non-null object'] }; | ||
| } | ||
| if (!targetPath || !path.isAbsolute(targetPath)) { | ||
| return { success: false, errors: ['targetPath must be an absolute path'] }; | ||
| } | ||
| // Serialize to YAML | ||
| let yamlContent; | ||
| try { | ||
| yamlContent = yaml.dump(spec, { | ||
| indent: 2, | ||
| lineWidth: 120, | ||
| noRefs: true, | ||
| sortKeys: false, | ||
| quotingType: '"', | ||
| }); | ||
| } catch (err) { | ||
| return { success: false, errors: [`YAML serialization failed: ${err.message}`] }; | ||
| } | ||
| // Atomic write: .tmp → validate → rename | ||
| const tmpPath = targetPath + '.tmp'; | ||
| try { | ||
| await fs.ensureDir(path.dirname(targetPath)); | ||
| await fs.writeFile(tmpPath, yamlContent, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, errors: [`Failed to write temp file: ${err.message}`] }; | ||
| } | ||
| // Validate: re-read and parse to confirm round-trip integrity | ||
| try { | ||
| const reRead = await fs.readFile(tmpPath, 'utf8'); | ||
| const parsed = yaml.load(reRead); | ||
| if (!parsed || typeof parsed !== 'object') { | ||
| await fs.remove(tmpPath); | ||
| return { success: false, errors: ['Round-trip validation failed: parsed result is empty or not an object'] }; | ||
| } | ||
| // Verify key fields survived serialization | ||
| const checks = [ | ||
| ['team_name_kebab', parsed.team_name_kebab, spec.team_name_kebab], | ||
| ['composition_pattern', parsed.composition_pattern, spec.composition_pattern], | ||
| ['agents.length', (parsed.agents || []).length, (spec.agents || []).length], | ||
| ]; | ||
| for (const [field, actual, expected] of checks) { | ||
| if (actual !== expected) { | ||
| await fs.remove(tmpPath); | ||
| return { success: false, errors: [`Round-trip validation failed: ${field} mismatch ("${actual}" vs "${expected}")`] }; | ||
| } | ||
| } | ||
| } catch (err) { | ||
| await fs.remove(tmpPath).catch(() => {}); | ||
| return { success: false, errors: [`Round-trip validation failed: ${err.message}`] }; | ||
| } | ||
| // Rename .tmp → target | ||
| try { | ||
| await fs.rename(tmpPath, targetPath); | ||
| } catch (err) { | ||
| await fs.remove(tmpPath).catch(() => {}); | ||
| return { success: false, errors: [`Failed to rename temp file to target: ${err.message}`] }; | ||
| } | ||
| return { success: true, errors: [] }; | ||
| } | ||
| /** | ||
| * Update specific fields in an existing spec file. | ||
| * Loads current spec, merges updates, writes back atomically. | ||
| * | ||
| * @param {string} specPath - Absolute path to existing spec file | ||
| * @param {Object} updates - Fields to update (shallow merge at top level, deep merge for nested objects) | ||
| * @returns {Promise<{ success: boolean, errors: string[] }>} | ||
| */ | ||
| async function updateSpec(specPath, updates) { | ||
| let raw; | ||
| try { | ||
| raw = await fs.readFile(specPath, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, errors: [`Cannot read spec file: ${err.message}`] }; | ||
| } | ||
| let current; | ||
| try { | ||
| current = yaml.load(raw); | ||
| } catch (err) { | ||
| return { success: false, errors: [`Invalid YAML in existing spec: ${err.message}`] }; | ||
| } | ||
| // Shallow merge with deep merge for known nested objects | ||
| const merged = { ...current }; | ||
| for (const [key, value] of Object.entries(updates)) { | ||
| if (key === 'progress' && typeof value === 'object' && typeof merged.progress === 'object') { | ||
| merged.progress = { ...merged.progress, ...value }; | ||
| } else if (key === 'integration' && typeof value === 'object' && typeof merged.integration === 'object') { | ||
| merged.integration = { ...merged.integration, ...value }; | ||
| } else if (key === 'metrics' && typeof value === 'object' && typeof merged.metrics === 'object') { | ||
| merged.metrics = { ...merged.metrics, ...value }; | ||
| } else { | ||
| merged[key] = value; | ||
| } | ||
| } | ||
| return writeSpec(merged, specPath); | ||
| } | ||
| module.exports = { | ||
| writeSpec, | ||
| updateSpec, | ||
| }; |
| 'use strict'; | ||
| /** | ||
| * JSDoc type definitions for Team Factory integration wiring modules. | ||
| * Canonical source for all factory shapes — no runtime code. | ||
| */ | ||
| /** | ||
| * Parsed team spec file shape. | ||
| * @typedef {Object} TeamSpec | ||
| * @property {string} schema_version | ||
| * @property {string} team_name | ||
| * @property {string} team_name_kebab | ||
| * @property {string} [description] - Optional team description for config.yaml | ||
| * @property {string} composition_pattern - "Sequential" or "Independent" | ||
| * @property {string} created - ISO date string | ||
| * @property {string} factory_version | ||
| * @property {AgentSpec[]} agents | ||
| * @property {ContractSpec[]} contracts | ||
| * @property {ContractSpec[]} feedback_contracts | ||
| * @property {IntegrationSpec} integration | ||
| * @property {Object} progress | ||
| */ | ||
| /** | ||
| * Agent specification from spec file. | ||
| * @typedef {Object} AgentSpec | ||
| * @property {string} id | ||
| * @property {string} role | ||
| * @property {string[]} capabilities | ||
| * @property {number} [pipeline_position] - Sequential only | ||
| * @property {string[]} overlap_acknowledgments | ||
| */ | ||
| /** | ||
| * Contract specification from spec file. | ||
| * @typedef {Object} ContractSpec | ||
| * @property {string} id | ||
| * @property {string} source_agent | ||
| * @property {string[]} target_agents | ||
| * @property {string} artifact_title | ||
| * @property {string} artifact_description | ||
| * @property {string[]} key_sections | ||
| * @property {string} file_name | ||
| * @property {string[]} input_artifacts | ||
| * @property {boolean} optional | ||
| */ | ||
| /** | ||
| * Integration decisions from spec file. | ||
| * @typedef {Object} IntegrationSpec | ||
| * @property {string} output_directory | ||
| * @property {string} compass_routing - "optional", "per-agent", "required", or "shared-reference" | ||
| * @property {string} [contract_prefix] | ||
| */ | ||
| /** | ||
| * Config.yaml data shape (matches Gyre/Vortex schema). | ||
| * @typedef {Object} ConfigData | ||
| * @property {string} submodule_name | ||
| * @property {string} description | ||
| * @property {string} module | ||
| * @property {string} output_folder | ||
| * @property {string[]} agents | ||
| * @property {string[]} workflows | ||
| * @property {string} version | ||
| * @property {string} user_name | ||
| * @property {string} communication_language | ||
| * @property {boolean} party_mode_enabled | ||
| * @property {string} core_module | ||
| */ | ||
| /** | ||
| * Module-help.csv row shape. | ||
| * @typedef {Object} CsvRow | ||
| * @property {string} module | ||
| * @property {string} phase | ||
| * @property {string} name | ||
| * @property {string} code | ||
| * @property {number} sequence | ||
| * @property {string} workflow_file | ||
| * @property {string} command | ||
| * @property {string} required | ||
| * @property {string} agent | ||
| * @property {string} options | ||
| * @property {string} description | ||
| * @property {string} output_location | ||
| * @property {string} outputs | ||
| */ | ||
| /** | ||
| * Activation validation result per agent. | ||
| * @typedef {Object} ActivationResult | ||
| * @property {string} agentFile | ||
| * @property {ActivationCheck[]} checks | ||
| * @property {string[]} errors | ||
| */ | ||
| /** | ||
| * Individual activation check. | ||
| * @typedef {Object} ActivationCheck | ||
| * @property {string} check - Description of what was checked | ||
| * @property {boolean} passed | ||
| * @property {string} [detail] - Additional info if failed | ||
| */ | ||
| /** | ||
| * Collision detection result. | ||
| * @typedef {Object} Collision | ||
| * @property {string} field - "submodule_name", "agent", or "workflow" | ||
| * @property {string} value - The colliding value | ||
| * @property {string} existingModule - Module that already has this value | ||
| */ | ||
| /** | ||
| * Creator result shape (shared by config-creator and csv-creator). | ||
| * @typedef {Object} CreatorResult | ||
| * @property {boolean} success | ||
| * @property {string} [filePath] | ||
| * @property {string[]} errors | ||
| * @property {number} [rowCount] - csv-creator only | ||
| * @property {Collision[]} [collisions] - config-creator only | ||
| */ | ||
| /** | ||
| * Activation validator result shape. | ||
| * @typedef {Object} ValidationResult | ||
| * @property {boolean} valid | ||
| * @property {ActivationResult[]} results | ||
| */ | ||
| /** | ||
| * Registry writer result shape (Full Write Safety Protocol). | ||
| * Differs from CreatorResult intentionally — writers return written[]/skipped[] per architecture rule 2. | ||
| * @typedef {Object} RegistryResult | ||
| * @property {boolean} success | ||
| * @property {string[]} written - Const names added to module.exports | ||
| * @property {string[]} skipped - Reasons for skipping (e.g., 'block already exists') | ||
| * @property {string[]} errors | ||
| * @property {boolean} rollbackApplied - True if .bak was restored after verify failure | ||
| * @property {boolean} [dirty] - True if dirty-tree detection found uncommitted changes | ||
| * @property {string} [diff] - Git diff output when dirty | ||
| */ | ||
| /** | ||
| * Agent entry in agent-registry.js. | ||
| * @typedef {Object} RegistryAgentEntry | ||
| * @property {string} id | ||
| * @property {string} name - Display name (first name or derived) | ||
| * @property {string} icon - Unicode emoji character | ||
| * @property {string} title - Role-based title | ||
| * @property {string} stream - Team name kebab | ||
| * @property {RegistryPersona} persona | ||
| */ | ||
| /** | ||
| * Persona sub-object within a registry agent entry. | ||
| * @typedef {Object} RegistryPersona | ||
| * @property {string} role | ||
| * @property {string} identity | ||
| * @property {string} communication_style | ||
| * @property {string} expertise | ||
| */ | ||
| /** | ||
| * File manifest entry tracking a created or modified file. | ||
| * @typedef {Object} ManifestEntry | ||
| * @property {string} path - File path (relative to project root) | ||
| * @property {'created' | 'modified'} operation - Whether the file was created or modified | ||
| * @property {string} module - Team name kebab identifying the owning module | ||
| */ | ||
| /** | ||
| * End-to-end validation result. | ||
| * @typedef {Object} E2EValidationResult | ||
| * @property {boolean} valid - True if all checks passed | ||
| * @property {E2ECheck[]} checks - Individual check results | ||
| * @property {string[]} errors - Human-readable error messages for failed checks | ||
| */ | ||
| /** | ||
| * Individual end-to-end validation check. | ||
| * Name uses {PROP}-{SEMANTIC-NAME} format per architecture (line 590). | ||
| * @typedef {Object} E2ECheck | ||
| * @property {string} name - Check ID in {PROP}-{SEMANTIC-NAME} format (e.g., CONFIG-EXISTS) | ||
| * @property {string} stepName - Step that produced the check (e.g., 'structural', 'regression', 'wiring') | ||
| * @property {boolean} passed - Whether the check passed | ||
| * @property {string} [expected] - Expected value (included on failure per TF-NFR11) | ||
| * @property {string} [actual] - Actual value found (included on failure per TF-NFR11) | ||
| * @property {string} [detail] - Additional context (e.g., file path) | ||
| */ | ||
| module.exports = {}; |
| 'use strict'; | ||
| /** | ||
| * RFC 4180-aware CSV row parser for team factory validation. | ||
| * Handles quoted fields containing commas and escaped quotes (""). | ||
| */ | ||
| /** | ||
| * Parse a single CSV row into an array of field values. | ||
| * Handles double-quoted fields per RFC 4180: | ||
| * - Fields containing commas, quotes, or newlines are enclosed in double quotes | ||
| * - Double quotes inside quoted fields are escaped as "" | ||
| * | ||
| * @param {string} line - A single CSV row string | ||
| * @returns {string[]} Array of field values | ||
| */ | ||
| function parseCsvRow(line) { | ||
| const fields = []; | ||
| let current = ''; | ||
| let inQuotes = false; | ||
| let i = 0; | ||
| while (i < line.length) { | ||
| const ch = line[i]; | ||
| if (inQuotes) { | ||
| if (ch === '"') { | ||
| // Check for escaped quote "" | ||
| if (i + 1 < line.length && line[i + 1] === '"') { | ||
| current += '"'; | ||
| i += 2; | ||
| } else { | ||
| // End of quoted field | ||
| inQuotes = false; | ||
| i++; | ||
| } | ||
| } else { | ||
| current += ch; | ||
| i++; | ||
| } | ||
| } else { | ||
| if (ch === '"') { | ||
| inQuotes = true; | ||
| i++; | ||
| } else if (ch === ',') { | ||
| fields.push(current); | ||
| current = ''; | ||
| i++; | ||
| } else { | ||
| current += ch; | ||
| i++; | ||
| } | ||
| } | ||
| } | ||
| // Push the last field | ||
| fields.push(current); | ||
| return fields; | ||
| } | ||
| module.exports = { parseCsvRow }; |
| 'use strict'; | ||
| /** | ||
| * Shared naming utilities for team factory modules. | ||
| * Canonical source for toKebab() and deriveWorkflowName() — no duplication. | ||
| */ | ||
| /** | ||
| * Convert a string to kebab-case. | ||
| * @param {string} str | ||
| * @returns {string} | ||
| */ | ||
| function toKebab(str) { | ||
| if (!str) return ''; | ||
| return str | ||
| .toLowerCase() | ||
| .replace(/[^a-z0-9]+/g, '-') | ||
| .replace(/^-+|-+$/g, ''); | ||
| } | ||
| /** | ||
| * Derive a workflow name for a single agent from spec data. | ||
| * Uses workflow_names map if present, otherwise derives from first capability. | ||
| * Falls back to role > agent.id when capabilities are absent. | ||
| * | ||
| * @param {Object} agent - Agent spec object with id, role, capabilities | ||
| * @param {Object} [specData] - Full spec data (checked for workflow_names map) | ||
| * @returns {string} kebab-case workflow name | ||
| */ | ||
| function deriveWorkflowName(agent, specData) { | ||
| if (specData && specData.workflow_names && specData.workflow_names[agent.id]) { | ||
| return specData.workflow_names[agent.id]; | ||
| } | ||
| if (agent.capabilities && agent.capabilities.length > 0) { | ||
| const cap = agent.capabilities[0]; | ||
| const wordCount = cap.trim().split(/\s+/).length; | ||
| if (wordCount > 4 && agent.role) { | ||
| return toKebab(agent.role); | ||
| } | ||
| return toKebab(cap); | ||
| } | ||
| return toKebab(agent.role || agent.id); | ||
| } | ||
| module.exports = { toKebab, deriveWorkflowName }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const yaml = require('js-yaml'); | ||
| const { verifyRequire, buildExportNames } = require('../writers/registry-writer'); | ||
| const { parseCsvRow } = require('../utils/csv-utils'); | ||
| /** @typedef {import('../types/factory-types').E2EValidationResult} E2EValidationResult */ | ||
| /** @typedef {import('../types/factory-types').E2ECheck} E2ECheck */ | ||
| /** | ||
| * Run end-to-end validation on a factory-created team. | ||
| * | ||
| * Checks: structural (config, csv, agents, workflows, contracts), | ||
| * wiring (registry, activation), and regression (registry require, Vortex validation). | ||
| * | ||
| * @param {Object} specData - Parsed team spec | ||
| * @param {Object} generationContext - Context from Step 4 | ||
| * @param {string} projectRoot - Absolute path to project root | ||
| * @returns {Promise<E2EValidationResult>} | ||
| */ | ||
| async function validateTeam(specData, generationContext, projectRoot) { | ||
| const checks = []; | ||
| const errors = []; | ||
| // --- Structural checks --- | ||
| checks.push(...checkConfig(generationContext)); | ||
| checks.push(...checkCsv(generationContext, specData)); | ||
| checks.push(...checkAgentFiles(generationContext)); | ||
| checks.push(...checkWorkflowDirs(generationContext)); | ||
| checks.push(...checkContractFiles(generationContext)); | ||
| // --- Wiring checks --- | ||
| checks.push(checkRegistryWiring(generationContext)); | ||
| checks.push(checkActivation(generationContext)); | ||
| // --- Regression checks --- | ||
| checks.push(checkRegistryRegression(projectRoot)); | ||
| checks.push(await checkVortexRegression(projectRoot)); | ||
| const valid = checks.every(c => c.passed); | ||
| if (!valid) { | ||
| for (const c of checks) { | ||
| if (!c.passed) { | ||
| errors.push(`${c.name}: expected ${c.expected || '(pass)'}, got ${c.actual || '(fail)'}`); | ||
| } | ||
| } | ||
| } | ||
| return { valid, checks, errors }; | ||
| } | ||
| // ── Structural checks ──────────────────────────────────────────────── | ||
| /** | ||
| * Check config.yaml: exists, parseable, has required fields. | ||
| * @param {Object} ctx - Generation context | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkConfig(ctx) { | ||
| const checks = []; | ||
| const configPath = ctx.config_yaml_path; | ||
| // EXISTS | ||
| const exists = configPath && fs.existsSync(configPath); | ||
| checks.push({ | ||
| name: 'CONFIG-EXISTS', | ||
| stepName: 'structural', | ||
| passed: !!exists, | ||
| expected: 'file exists', | ||
| actual: exists ? 'file exists' : 'file not found', | ||
| detail: configPath || '(no path provided)', | ||
| }); | ||
| if (!exists) return checks; | ||
| // PARSEABLE | ||
| let config; | ||
| try { | ||
| config = yaml.load(fs.readFileSync(configPath, 'utf8')); | ||
| checks.push({ | ||
| name: 'CONFIG-PARSEABLE', | ||
| stepName: 'structural', | ||
| passed: true, | ||
| detail: configPath, | ||
| }); | ||
| } catch (err) { | ||
| checks.push({ | ||
| name: 'CONFIG-PARSEABLE', | ||
| stepName: 'structural', | ||
| passed: false, | ||
| expected: 'valid YAML', | ||
| actual: `parse error: ${err.message}`, | ||
| detail: configPath, | ||
| }); | ||
| return checks; | ||
| } | ||
| // REQUIRED FIELDS | ||
| const required = ['submodule_name', 'module', 'agents', 'workflows']; | ||
| const missing = required.filter(f => !config[f]); | ||
| checks.push({ | ||
| name: 'CONFIG-REQUIRED-FIELDS', | ||
| stepName: 'structural', | ||
| passed: missing.length === 0, | ||
| expected: required.join(', '), | ||
| actual: missing.length === 0 ? 'all present' : `missing: ${missing.join(', ')}`, | ||
| detail: configPath, | ||
| }); | ||
| return checks; | ||
| } | ||
| /** | ||
| * Check module-help.csv: exists, correct header, correct row count. | ||
| * @param {Object} ctx - Generation context | ||
| * @param {Object} specData - Team spec | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkCsv(ctx, specData) { | ||
| const checks = []; | ||
| const csvPath = ctx.module_help_csv_path; | ||
| // EXISTS | ||
| const exists = csvPath && fs.existsSync(csvPath); | ||
| checks.push({ | ||
| name: 'CSV-EXISTS', | ||
| stepName: 'structural', | ||
| passed: !!exists, | ||
| expected: 'file exists', | ||
| actual: exists ? 'file exists' : 'file not found', | ||
| detail: csvPath || '(no path provided)', | ||
| }); | ||
| if (!exists) return checks; | ||
| const content = fs.readFileSync(csvPath, 'utf8'); | ||
| const lines = content.trim().split('\n'); | ||
| // HEADER | ||
| const expectedHeader = 'module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs,'; | ||
| const actualHeader = lines[0] || ''; | ||
| checks.push({ | ||
| name: 'CSV-HEADER', | ||
| stepName: 'structural', | ||
| passed: actualHeader.trim() === expectedHeader, | ||
| expected: expectedHeader, | ||
| actual: actualHeader.trim(), | ||
| detail: csvPath, | ||
| }); | ||
| // ROW COUNT (data rows = agent count) | ||
| const dataRows = lines.length - 1; // subtract header | ||
| const agentCount = (specData.agents || []).length; | ||
| checks.push({ | ||
| name: 'CSV-ROW-COUNT', | ||
| stepName: 'structural', | ||
| passed: dataRows === agentCount, | ||
| expected: `${agentCount} rows`, | ||
| actual: `${dataRows} rows`, | ||
| detail: csvPath, | ||
| }); | ||
| return checks; | ||
| } | ||
| /** | ||
| * Check all agent files exist. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkAgentFiles(ctx) { | ||
| const checks = []; | ||
| for (const agentFile of (ctx.agent_files || [])) { | ||
| const exists = fs.existsSync(agentFile); | ||
| checks.push({ | ||
| name: 'AGENT-FILE-EXISTS', | ||
| stepName: 'structural', | ||
| passed: exists, | ||
| expected: 'file exists', | ||
| actual: exists ? 'file exists' : 'file not found', | ||
| detail: agentFile, | ||
| }); | ||
| } | ||
| return checks; | ||
| } | ||
| /** | ||
| * Check all workflow directories exist. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkWorkflowDirs(ctx) { | ||
| const checks = []; | ||
| for (const wfDir of (ctx.workflow_dirs || [])) { | ||
| const exists = fs.existsSync(wfDir); | ||
| checks.push({ | ||
| name: 'WORKFLOW-DIR-EXISTS', | ||
| stepName: 'structural', | ||
| passed: exists, | ||
| expected: 'directory exists', | ||
| actual: exists ? 'directory exists' : 'directory not found', | ||
| detail: wfDir, | ||
| }); | ||
| } | ||
| return checks; | ||
| } | ||
| /** | ||
| * Check all contract files exist. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkContractFiles(ctx) { | ||
| const checks = []; | ||
| for (const contractFile of (ctx.contract_files || [])) { | ||
| const exists = fs.existsSync(contractFile); | ||
| checks.push({ | ||
| name: 'CONTRACT-FILE-EXISTS', | ||
| stepName: 'structural', | ||
| passed: exists, | ||
| expected: 'file exists', | ||
| actual: exists ? 'file exists' : 'file not found', | ||
| detail: contractFile, | ||
| }); | ||
| } | ||
| return checks; | ||
| } | ||
| // ── Wiring checks ──────────────────────────────────────────────────── | ||
| /** | ||
| * Check registry wiring result from Step 4. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkRegistryWiring(ctx) { | ||
| const result = ctx.registry_wiring_result || {}; | ||
| const expectedCount = buildExportNames('X').length; // derive from canonical source | ||
| const passed = result.success === true && Array.isArray(result.written) && result.written.length === expectedCount; | ||
| return { | ||
| name: 'REGISTRY-WIRING', | ||
| stepName: 'wiring', | ||
| passed, | ||
| expected: `success with ${expectedCount} exports`, | ||
| actual: result.success ? `success with ${(result.written || []).length} exports` : `failed: ${(result.errors || []).join(', ')}`, | ||
| detail: result.skipped && result.skipped.length > 0 ? `skipped: ${result.skipped.join(', ')}` : undefined, | ||
| }; | ||
| } | ||
| /** | ||
| * Check activation validation result from Step 4. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkActivation(ctx) { | ||
| const result = ctx.activation_validation_results || {}; | ||
| return { | ||
| name: 'ACTIVATION-VALID', | ||
| stepName: 'wiring', | ||
| passed: result.valid === true, | ||
| expected: 'valid', | ||
| actual: result.valid === true ? 'valid' : 'invalid', | ||
| detail: result.valid === true ? undefined : JSON.stringify(result.results || []), | ||
| }; | ||
| } | ||
| // ── Regression checks ──────────────────────────────────────────────── | ||
| /** | ||
| * Verify agent-registry.js still loads via require(). | ||
| * Reuses verifyRequire from registry-writer.js. | ||
| * @param {string} projectRoot | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkRegistryRegression(projectRoot) { | ||
| const registryPath = path.join(projectRoot, 'scripts/update/lib/agent-registry.js'); | ||
| const error = verifyRequire(registryPath); | ||
| return { | ||
| name: 'REGISTRY-REGRESSION', | ||
| stepName: 'regression', | ||
| passed: error === null, | ||
| expected: 'require() succeeds', | ||
| actual: error === null ? 'require() succeeds' : error, | ||
| detail: registryPath, | ||
| }; | ||
| } | ||
| /** | ||
| * Run existing Vortex validateInstallation() to confirm native team still passes. | ||
| * @param {string} projectRoot | ||
| * @returns {Promise<E2ECheck>} | ||
| */ | ||
| async function checkVortexRegression(projectRoot) { | ||
| const validatorPath = path.join(projectRoot, 'scripts/update/lib/validator.js'); | ||
| if (!fs.existsSync(validatorPath)) { | ||
| return { | ||
| name: 'VORTEX-REGRESSION', | ||
| stepName: 'regression', | ||
| passed: false, | ||
| expected: 'validator.js exists', | ||
| actual: `validator.js not found at ${validatorPath}`, | ||
| detail: validatorPath, | ||
| }; | ||
| } | ||
| try { | ||
| const { validateInstallation } = require(validatorPath); | ||
| const result = await validateInstallation({}, projectRoot); | ||
| const failedChecks = (result.checks || []).filter(c => !c.passed); | ||
| return { | ||
| name: 'VORTEX-REGRESSION', | ||
| stepName: 'regression', | ||
| passed: result.valid === true, | ||
| expected: 'all Vortex checks pass', | ||
| actual: result.valid ? 'all Vortex checks pass' : `${failedChecks.length} check(s) failed: ${failedChecks.map(c => c.name).join(', ')}`, | ||
| detail: validatorPath, | ||
| }; | ||
| } catch (err) { | ||
| return { | ||
| name: 'VORTEX-REGRESSION', | ||
| stepName: 'regression', | ||
| passed: false, | ||
| expected: 'validateInstallation() succeeds', | ||
| actual: `error: ${err.message}`, | ||
| detail: path.join(projectRoot, 'scripts/update/lib/validator.js'), | ||
| }; | ||
| } | ||
| } | ||
| // ── Extension validation ────────────────────────────────────────────── | ||
| /** | ||
| * Run end-to-end validation on an agent extension (add-agent-to-existing-team). | ||
| * | ||
| * Checks: append results (registry, config, CSV), structural (new agent files, | ||
| * new workflow dirs), regression (existing agents unchanged, registry require). | ||
| * | ||
| * @param {Object} extensionContext - Context from the add-agent workflow | ||
| * @param {string} extensionContext.new_agent_id - New agent ID | ||
| * @param {string[]} extensionContext.existing_agent_ids - Existing agent IDs to verify unchanged | ||
| * @param {string[]} extensionContext.new_agent_files - New agent .md file paths | ||
| * @param {string[]} extensionContext.new_workflow_dirs - New workflow directory paths | ||
| * @param {string[]} [extensionContext.new_contract_files] - New contract file paths | ||
| * @param {string} extensionContext.config_yaml_path - Path to config.yaml | ||
| * @param {string} extensionContext.module_help_csv_path - Path to module-help.csv | ||
| * @param {Object} extensionContext.registry_append_result - Result from appendAgentToBlock | ||
| * @param {Object} extensionContext.config_append_result - Result from appendConfigAgent | ||
| * @param {Object} extensionContext.csv_append_result - Result from appendCsvRow | ||
| * @param {string} projectRoot - Absolute path to project root | ||
| * @returns {Promise<E2EValidationResult>} | ||
| */ | ||
| async function validateExtension(extensionContext, projectRoot) { | ||
| const checks = []; | ||
| const errors = []; | ||
| // --- Append result checks --- | ||
| checks.push(checkRegistryAppend(extensionContext)); | ||
| checks.push(checkConfigAppend(extensionContext)); | ||
| checks.push(checkCsvAppend(extensionContext)); | ||
| // --- Structural checks for new files --- | ||
| checks.push(...checkNewAgentFiles(extensionContext)); | ||
| checks.push(...checkNewWorkflowDirs(extensionContext)); | ||
| checks.push(...checkNewContractFiles(extensionContext)); | ||
| // --- Extension regression: existing agents unchanged --- | ||
| checks.push(checkExistingAgentsRegistry(extensionContext, projectRoot)); | ||
| checks.push(checkExistingAgentsConfig(extensionContext)); | ||
| checks.push(checkExistingAgentsCsv(extensionContext)); | ||
| // --- Standard regression --- | ||
| checks.push(checkRegistryRegression(projectRoot)); | ||
| checks.push(await checkVortexRegression(projectRoot)); | ||
| const valid = checks.every(c => c.passed); | ||
| if (!valid) { | ||
| for (const c of checks) { | ||
| if (!c.passed) { | ||
| errors.push(`${c.name}: expected ${c.expected || '(pass)'}, got ${c.actual || '(fail)'}`); | ||
| } | ||
| } | ||
| } | ||
| return { valid, checks, errors }; | ||
| } | ||
| /** | ||
| * Check registry append result. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkRegistryAppend(ctx) { | ||
| const result = ctx.registry_append_result || {}; | ||
| return { | ||
| name: 'AGENT-REGISTRY-APPEND', | ||
| stepName: 'extension', | ||
| passed: result.success === true && Array.isArray(result.written) && result.written.includes(ctx.new_agent_id), | ||
| expected: `success with ${ctx.new_agent_id} written`, | ||
| actual: result.success ? `written: ${(result.written || []).join(', ')}` : `failed: ${(result.errors || []).join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Check config append result. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkConfigAppend(ctx) { | ||
| const result = ctx.config_append_result || {}; | ||
| return { | ||
| name: 'CONFIG-APPEND', | ||
| stepName: 'extension', | ||
| passed: result.success === true, | ||
| expected: 'success', | ||
| actual: result.success ? 'success' : `failed: ${(result.errors || []).join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Check CSV append result. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkCsvAppend(ctx) { | ||
| const result = ctx.csv_append_result || {}; | ||
| return { | ||
| name: 'CSV-APPEND', | ||
| stepName: 'extension', | ||
| passed: result.success === true, | ||
| expected: 'success', | ||
| actual: result.success ? `success (${result.rowCount} rows)` : `failed: ${(result.errors || []).join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Check new agent files exist. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkNewAgentFiles(ctx) { | ||
| const checks = []; | ||
| for (const agentFile of (ctx.new_agent_files || [])) { | ||
| const exists = fs.existsSync(agentFile); | ||
| checks.push({ | ||
| name: 'AGENT-FILE-EXISTS', | ||
| stepName: 'extension', | ||
| passed: exists, | ||
| expected: 'file exists', | ||
| actual: exists ? 'file exists' : 'file not found', | ||
| detail: agentFile, | ||
| }); | ||
| } | ||
| return checks; | ||
| } | ||
| /** | ||
| * Check new workflow directories exist. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkNewWorkflowDirs(ctx) { | ||
| const checks = []; | ||
| for (const wfDir of (ctx.new_workflow_dirs || [])) { | ||
| const exists = fs.existsSync(wfDir); | ||
| checks.push({ | ||
| name: 'WORKFLOW-DIR-EXISTS', | ||
| stepName: 'extension', | ||
| passed: exists, | ||
| expected: 'directory exists', | ||
| actual: exists ? 'directory exists' : 'directory not found', | ||
| detail: wfDir, | ||
| }); | ||
| } | ||
| return checks; | ||
| } | ||
| /** | ||
| * Check new contract files exist. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkNewContractFiles(ctx) { | ||
| const checks = []; | ||
| for (const contractFile of (ctx.new_contract_files || [])) { | ||
| const exists = fs.existsSync(contractFile); | ||
| checks.push({ | ||
| name: 'CONTRACT-FILE-EXISTS', | ||
| stepName: 'extension', | ||
| passed: exists, | ||
| expected: 'file exists', | ||
| actual: exists ? 'file exists' : 'file not found', | ||
| detail: contractFile, | ||
| }); | ||
| } | ||
| return checks; | ||
| } | ||
| /** | ||
| * Verify existing agents still present in agent-registry.js. | ||
| * @param {Object} ctx | ||
| * @param {string} projectRoot | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkExistingAgentsRegistry(ctx, projectRoot) { | ||
| const registryPath = path.join(projectRoot, 'scripts/update/lib/agent-registry.js'); | ||
| if (!fs.existsSync(registryPath)) { | ||
| return { | ||
| name: 'EXISTING-AGENTS-REGISTRY', | ||
| stepName: 'extension-regression', | ||
| passed: false, | ||
| expected: 'existing agents preserved in registry', | ||
| actual: 'agent-registry.js not found', | ||
| }; | ||
| } | ||
| const content = fs.readFileSync(registryPath, 'utf8'); | ||
| const existingIds = (ctx.existing_agent_ids || []); | ||
| // If none of the existing agents appear in the registry, they are team-local only — skip gracefully | ||
| if (existingIds.length > 0 && existingIds.every(id => !content.includes(`id: '${id}'`))) { | ||
| return { | ||
| name: 'EXISTING-AGENTS-REGISTRY', | ||
| stepName: 'extension-regression', | ||
| passed: true, | ||
| expected: 'existing agents preserved in registry', | ||
| actual: 'existing agents not in registry scope (team-local only)', | ||
| }; | ||
| } | ||
| // Check each existing agent that IS in the registry — it must still be there | ||
| const missing = existingIds.filter(id => !content.includes(`id: '${id}'`)); | ||
| return { | ||
| name: 'EXISTING-AGENTS-REGISTRY', | ||
| stepName: 'extension-regression', | ||
| passed: missing.length === 0, | ||
| expected: 'existing agents preserved in registry', | ||
| actual: missing.length === 0 ? 'all preserved' : `missing: ${missing.join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Verify existing agents still present in config.yaml. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkExistingAgentsConfig(ctx) { | ||
| const configPath = ctx.config_yaml_path; | ||
| if (!configPath || !fs.existsSync(configPath)) { | ||
| return { | ||
| name: 'EXISTING-AGENTS-CONFIG', | ||
| stepName: 'extension-regression', | ||
| passed: false, | ||
| expected: 'existing agents preserved', | ||
| actual: 'config.yaml not found', | ||
| }; | ||
| } | ||
| let config; | ||
| try { | ||
| config = yaml.load(fs.readFileSync(configPath, 'utf8')); | ||
| } catch { | ||
| return { | ||
| name: 'EXISTING-AGENTS-CONFIG', | ||
| stepName: 'extension-regression', | ||
| passed: false, | ||
| expected: 'existing agents preserved', | ||
| actual: 'cannot parse config.yaml', | ||
| }; | ||
| } | ||
| const agents = config.agents || []; | ||
| const missing = (ctx.existing_agent_ids || []).filter(id => !agents.includes(id)); | ||
| return { | ||
| name: 'EXISTING-AGENTS-CONFIG', | ||
| stepName: 'extension-regression', | ||
| passed: missing.length === 0, | ||
| expected: 'existing agents preserved', | ||
| actual: missing.length === 0 ? 'all preserved' : `missing: ${missing.join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Verify existing agents still present in module-help.csv. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkExistingAgentsCsv(ctx) { | ||
| const csvPath = ctx.module_help_csv_path; | ||
| if (!csvPath || !fs.existsSync(csvPath)) { | ||
| return { | ||
| name: 'EXISTING-AGENTS-CSV', | ||
| stepName: 'extension-regression', | ||
| passed: false, | ||
| expected: 'existing agent rows preserved', | ||
| actual: 'module-help.csv not found', | ||
| }; | ||
| } | ||
| const content = fs.readFileSync(csvPath, 'utf8'); | ||
| const lines = content.trim().split('\n').slice(1); // skip header | ||
| // Extract agent column (index 8) from each row using RFC 4180-aware parser. | ||
| const agentIds = lines.map(line => { | ||
| const cols = parseCsvRow(line); | ||
| return cols[8] || ''; | ||
| }); | ||
| const missing = (ctx.existing_agent_ids || []).filter(id => !agentIds.includes(id)); | ||
| return { | ||
| name: 'EXISTING-AGENTS-CSV', | ||
| stepName: 'extension-regression', | ||
| passed: missing.length === 0, | ||
| expected: 'existing agent rows preserved', | ||
| actual: missing.length === 0 ? 'all preserved' : `missing: ${missing.join(', ')}`, | ||
| }; | ||
| } | ||
| // ── Skill extension validation ──────────────────────────────────────── | ||
| /** | ||
| * Run end-to-end validation on a skill/workflow extension (add-skill-to-existing-agent). | ||
| * | ||
| * Checks: append results (registry workflow, config workflow, CSV), structural (workflow files), | ||
| * regression (existing workflows unchanged, registry require). | ||
| * | ||
| * @param {Object} skillContext - Context from the add-skill workflow | ||
| * @param {string} skillContext.new_workflow_name - New workflow name | ||
| * @param {string} skillContext.agent_id - Target agent ID | ||
| * @param {string[]} skillContext.existing_workflow_names - Existing workflow names to verify unchanged | ||
| * @param {string[]} skillContext.new_workflow_files - New workflow file paths (workflow.md, template) | ||
| * @param {string} skillContext.config_yaml_path - Path to config.yaml | ||
| * @param {string} skillContext.module_help_csv_path - Path to module-help.csv | ||
| * @param {Object} skillContext.registry_append_result - Result from appendWorkflowToBlock | ||
| * @param {Object} skillContext.config_append_result - Result from appendConfigWorkflow | ||
| * @param {Object} skillContext.csv_append_result - Result from appendCsvRow | ||
| * @param {string} projectRoot - Absolute path to project root | ||
| * @returns {Promise<E2EValidationResult>} | ||
| */ | ||
| async function validateSkillExtension(skillContext, projectRoot) { | ||
| const checks = []; | ||
| const errors = []; | ||
| // --- Append result checks --- | ||
| checks.push(checkWorkflowRegistryAppend(skillContext)); | ||
| checks.push(checkConfigWorkflowAppend(skillContext)); | ||
| checks.push(checkCsvWorkflowAppend(skillContext)); | ||
| // --- Structural checks for new files --- | ||
| checks.push(...checkNewWorkflowFiles(skillContext)); | ||
| // --- Activation menu check --- | ||
| checks.push(checkActivationMenuUpdated(skillContext)); | ||
| // --- Extension regression: existing workflows unchanged --- | ||
| checks.push(checkExistingWorkflowsRegistry(skillContext, projectRoot)); | ||
| checks.push(checkExistingWorkflowsConfig(skillContext)); | ||
| checks.push(checkExistingWorkflowsCsv(skillContext)); | ||
| // --- Standard regression --- | ||
| checks.push(checkRegistryRegression(projectRoot)); | ||
| checks.push(await checkVortexRegression(projectRoot)); | ||
| const valid = checks.every(c => c.passed); | ||
| if (!valid) { | ||
| for (const c of checks) { | ||
| if (!c.passed) { | ||
| errors.push(`${c.name}: expected ${c.expected || '(pass)'}, got ${c.actual || '(fail)'}`); | ||
| } | ||
| } | ||
| } | ||
| return { valid, checks, errors }; | ||
| } | ||
| /** | ||
| * Check registry workflow append result. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkWorkflowRegistryAppend(ctx) { | ||
| const result = ctx.registry_append_result || {}; | ||
| return { | ||
| name: 'WORKFLOW-REGISTRY-APPEND', | ||
| stepName: 'skill-extension', | ||
| passed: result.success === true && Array.isArray(result.written) && result.written.includes(ctx.new_workflow_name), | ||
| expected: `success with ${ctx.new_workflow_name} written`, | ||
| actual: result.success ? `written: ${(result.written || []).join(', ')}` : `failed: ${(result.errors || []).join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Check config workflow append result. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkConfigWorkflowAppend(ctx) { | ||
| const result = ctx.config_append_result || {}; | ||
| return { | ||
| name: 'CONFIG-WORKFLOW-APPEND', | ||
| stepName: 'skill-extension', | ||
| passed: result.success === true, | ||
| expected: 'success', | ||
| actual: result.success ? 'success' : `failed: ${(result.errors || []).join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Check CSV workflow append result. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkCsvWorkflowAppend(ctx) { | ||
| const result = ctx.csv_append_result || {}; | ||
| return { | ||
| name: 'CSV-WORKFLOW-APPEND', | ||
| stepName: 'skill-extension', | ||
| passed: result.success === true, | ||
| expected: 'success', | ||
| actual: result.success ? `success (${result.rowCount} rows)` : `failed: ${(result.errors || []).join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Check new workflow files exist on disk. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck[]} | ||
| */ | ||
| function checkNewWorkflowFiles(ctx) { | ||
| const checks = []; | ||
| for (const wfFile of (ctx.new_workflow_files || [])) { | ||
| const exists = fs.existsSync(wfFile); | ||
| checks.push({ | ||
| name: 'WORKFLOW-FILE-EXISTS', | ||
| stepName: 'skill-extension', | ||
| passed: exists, | ||
| expected: 'file exists', | ||
| actual: exists ? 'file exists' : 'file not found', | ||
| detail: wfFile, | ||
| }); | ||
| } | ||
| return checks; | ||
| } | ||
| /** | ||
| * Check that the agent's activation menu contains a new <item> for the workflow. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkActivationMenuUpdated(ctx) { | ||
| const agentPath = ctx.agent_file_path; | ||
| if (!agentPath || !fs.existsSync(agentPath)) { | ||
| return { | ||
| name: 'ACTIVATION-MENU-UPDATED', | ||
| stepName: 'skill-extension', | ||
| passed: false, | ||
| expected: 'agent .md contains new <item> in <menu>', | ||
| actual: 'agent file not found', | ||
| }; | ||
| } | ||
| const content = fs.readFileSync(agentPath, 'utf8'); | ||
| const workflowName = ctx.new_workflow_name || ''; | ||
| const hasItem = content.includes(`workflows/${workflowName}/`) && content.includes('<item'); | ||
| return { | ||
| name: 'ACTIVATION-MENU-UPDATED', | ||
| stepName: 'skill-extension', | ||
| passed: hasItem, | ||
| expected: `<item> with workflows/${workflowName}/ in agent menu`, | ||
| actual: hasItem ? 'found' : 'new workflow item not found in activation menu', | ||
| }; | ||
| } | ||
| /** | ||
| * Verify existing workflows still present in agent-registry.js. | ||
| * @param {Object} ctx | ||
| * @param {string} projectRoot | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkExistingWorkflowsRegistry(ctx, projectRoot) { | ||
| const registryPath = path.join(projectRoot, 'scripts/update/lib/agent-registry.js'); | ||
| if (!fs.existsSync(registryPath)) { | ||
| return { | ||
| name: 'EXISTING-WORKFLOWS-REGISTRY', | ||
| stepName: 'skill-extension-regression', | ||
| passed: false, | ||
| expected: 'existing workflows preserved in registry', | ||
| actual: 'agent-registry.js not found', | ||
| }; | ||
| } | ||
| const content = fs.readFileSync(registryPath, 'utf8'); | ||
| const existingNames = (ctx.existing_workflow_names || []); | ||
| // If none of the existing workflows appear in the registry, they are team-local only — skip gracefully | ||
| if (existingNames.length > 0 && existingNames.every(n => !content.includes(`name: '${n}'`))) { | ||
| return { | ||
| name: 'EXISTING-WORKFLOWS-REGISTRY', | ||
| stepName: 'skill-extension-regression', | ||
| passed: true, | ||
| expected: 'existing workflows preserved in registry', | ||
| actual: 'existing workflows not in registry scope (team-local only)', | ||
| }; | ||
| } | ||
| // Check each existing workflow that IS in the registry — it must still be there | ||
| const missing = existingNames.filter(n => !content.includes(`name: '${n}'`)); | ||
| return { | ||
| name: 'EXISTING-WORKFLOWS-REGISTRY', | ||
| stepName: 'skill-extension-regression', | ||
| passed: missing.length === 0, | ||
| expected: 'existing workflows preserved in registry', | ||
| actual: missing.length === 0 ? 'all preserved' : `missing: ${missing.join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Verify existing workflows still present in config.yaml. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkExistingWorkflowsConfig(ctx) { | ||
| const configPath = ctx.config_yaml_path; | ||
| if (!configPath || !fs.existsSync(configPath)) { | ||
| return { | ||
| name: 'EXISTING-WORKFLOWS-CONFIG', | ||
| stepName: 'skill-extension-regression', | ||
| passed: false, | ||
| expected: 'existing workflows preserved', | ||
| actual: 'config.yaml not found', | ||
| }; | ||
| } | ||
| let config; | ||
| try { | ||
| config = yaml.load(fs.readFileSync(configPath, 'utf8')); | ||
| } catch { | ||
| return { | ||
| name: 'EXISTING-WORKFLOWS-CONFIG', | ||
| stepName: 'skill-extension-regression', | ||
| passed: false, | ||
| expected: 'existing workflows preserved', | ||
| actual: 'cannot parse config.yaml', | ||
| }; | ||
| } | ||
| const workflows = config.workflows || []; | ||
| const missing = (ctx.existing_workflow_names || []).filter(n => !workflows.includes(n)); | ||
| return { | ||
| name: 'EXISTING-WORKFLOWS-CONFIG', | ||
| stepName: 'skill-extension-regression', | ||
| passed: missing.length === 0, | ||
| expected: 'existing workflows preserved', | ||
| actual: missing.length === 0 ? 'all preserved' : `missing: ${missing.join(', ')}`, | ||
| }; | ||
| } | ||
| /** | ||
| * Verify existing workflow rows still present in module-help.csv. | ||
| * @param {Object} ctx | ||
| * @returns {E2ECheck} | ||
| */ | ||
| function checkExistingWorkflowsCsv(ctx) { | ||
| const csvPath = ctx.module_help_csv_path; | ||
| if (!csvPath || !fs.existsSync(csvPath)) { | ||
| return { | ||
| name: 'EXISTING-WORKFLOWS-CSV', | ||
| stepName: 'skill-extension-regression', | ||
| passed: false, | ||
| expected: 'existing workflow rows preserved', | ||
| actual: 'module-help.csv not found', | ||
| }; | ||
| } | ||
| const content = fs.readFileSync(csvPath, 'utf8'); | ||
| const lines = content.trim().split('\n').slice(1); // skip header | ||
| // Extract workflow name column (index 2) from each row using RFC 4180-aware parser. | ||
| const workflowNames = lines.map(line => { | ||
| const cols = parseCsvRow(line); | ||
| return (cols[2] || '').trim(); | ||
| }); | ||
| const missing = (ctx.existing_workflow_names || []).filter(n => { | ||
| // Check by title case version (CSV stores title case) | ||
| const titleCase = n.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); | ||
| return !workflowNames.includes(titleCase) && !workflowNames.includes(n); | ||
| }); | ||
| return { | ||
| name: 'EXISTING-WORKFLOWS-CSV', | ||
| stepName: 'skill-extension-regression', | ||
| passed: missing.length === 0, | ||
| expected: 'existing workflow rows preserved', | ||
| actual: missing.length === 0 ? 'all preserved' : `missing: ${missing.join(', ')}`, | ||
| }; | ||
| } | ||
| module.exports = { | ||
| validateTeam, | ||
| validateExtension, | ||
| validateSkillExtension, | ||
| }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| /** @typedef {import('../types/factory-types')} Types */ | ||
| /** | ||
| * Regex to extract activation XML block from agent markdown files. | ||
| * Matches <activation ...>...</activation> including multiline content. | ||
| */ | ||
| const ACTIVATION_REGEX = /<activation[^>]*>([\s\S]*?)<\/activation>/; | ||
| /** | ||
| * Validate activation blocks in generated agent .md files. | ||
| * Read-only — this module NEVER writes to any file. | ||
| * | ||
| * Checks: | ||
| * 1. Activation block exists in the agent file | ||
| * 2. Config path reference points to the team's config.yaml | ||
| * 3. Module path reference is correct | ||
| * | ||
| * @param {string[]} agentFiles - Array of absolute paths to agent .md files | ||
| * @param {Object} moduleConfig - Module context for validation | ||
| * @param {string} moduleConfig.configPath - Expected config.yaml path | ||
| * @param {string} moduleConfig.modulePath - Expected module path (e.g., "bme/_team-name") | ||
| * @param {string} moduleConfig.moduleDir - Absolute path to module directory | ||
| * @returns {Promise<import('../types/factory-types').ValidationResult>} | ||
| */ | ||
| async function validateActivation(agentFiles, moduleConfig) { | ||
| const results = []; | ||
| for (const agentFile of agentFiles) { | ||
| const result = await validateSingleAgent(agentFile, moduleConfig); | ||
| results.push(result); | ||
| } | ||
| const valid = results.every(r => r.errors.length === 0); | ||
| return { valid, results }; | ||
| } | ||
| /** | ||
| * Validate a single agent file's activation block. | ||
| * @param {string} agentFile - Absolute path to agent .md file | ||
| * @param {Object} moduleConfig - Module context | ||
| * @returns {Promise<import('../types/factory-types').ActivationResult>} | ||
| */ | ||
| async function validateSingleAgent(agentFile, moduleConfig) { | ||
| const checks = []; | ||
| const errors = []; | ||
| // Read agent file | ||
| let content; | ||
| try { | ||
| content = await fs.readFile(agentFile, 'utf8'); | ||
| } catch (err) { | ||
| return { agentFile, checks: [], errors: [`Cannot read agent file: ${err.message}`] }; | ||
| } | ||
| // Check 1: Activation block exists | ||
| const match = content.match(ACTIVATION_REGEX); | ||
| if (!match) { | ||
| checks.push({ check: 'Activation block exists', passed: false, detail: 'No <activation> block found in agent file' }); | ||
| errors.push('No <activation> block found'); | ||
| return { agentFile, checks, errors }; | ||
| } | ||
| checks.push({ check: 'Activation block exists', passed: true }); | ||
| const activationContent = match[0]; | ||
| // Check 2: Config path reference | ||
| const configPathValid = activationContent.includes(moduleConfig.configPath); | ||
| checks.push({ | ||
| check: 'Config path reference', | ||
| passed: configPathValid, | ||
| detail: configPathValid ? undefined : `Expected reference to "${moduleConfig.configPath}" not found in activation block` | ||
| }); | ||
| if (!configPathValid) { | ||
| errors.push(`Config path "${moduleConfig.configPath}" not referenced in activation block`); | ||
| } | ||
| // Check 3: Config file exists on disk | ||
| const configAbsPath = path.resolve(moduleConfig.moduleDir, 'config.yaml'); | ||
| const configExists = await fs.pathExists(configAbsPath); | ||
| checks.push({ | ||
| check: 'Config file exists', | ||
| passed: configExists, | ||
| detail: configExists ? undefined : `Config file not found at ${configAbsPath}` | ||
| }); | ||
| if (!configExists) { | ||
| errors.push(`Config file does not exist at ${configAbsPath}`); | ||
| } | ||
| // Check 4: Module path reference — strict module= attribute match only | ||
| const moduleAttrRegex = /module\s*=\s*"([^"]*)"/; | ||
| const moduleAttrMatch = activationContent.match(moduleAttrRegex); | ||
| const modulePathValid = moduleAttrMatch | ||
| ? moduleAttrMatch[1] === moduleConfig.modulePath | ||
| : false; | ||
| checks.push({ | ||
| check: 'Module path reference', | ||
| passed: modulePathValid, | ||
| detail: modulePathValid | ||
| ? undefined | ||
| : moduleAttrMatch | ||
| ? `Expected module="${moduleConfig.modulePath}" but found module="${moduleAttrMatch[1]}" in activation block` | ||
| : `No module="..." attribute found in activation block` | ||
| }); | ||
| if (!modulePathValid) { | ||
| errors.push(`Module path "${moduleConfig.modulePath}" not referenced correctly in activation block`); | ||
| } | ||
| // Check 5: Module directory exists | ||
| const moduleDirExists = await fs.pathExists(moduleConfig.moduleDir); | ||
| checks.push({ | ||
| check: 'Module directory exists', | ||
| passed: moduleDirExists, | ||
| detail: moduleDirExists ? undefined : `Module directory not found at ${moduleConfig.moduleDir}` | ||
| }); | ||
| if (!moduleDirExists) { | ||
| errors.push(`Module directory does not exist at ${moduleConfig.moduleDir}`); | ||
| } | ||
| return { agentFile, checks, errors }; | ||
| } | ||
| // --- CLI entry point --- | ||
| if (require.main === module) { | ||
| const args = process.argv.slice(2); | ||
| const agentFilesIdx = args.indexOf('--agent-files'); | ||
| const configPathIdx = args.indexOf('--config-path'); | ||
| if (agentFilesIdx === -1 || configPathIdx === -1) { | ||
| console.error('Usage: node activation-validator.js --agent-files <glob-or-paths> --config-path <path>'); | ||
| process.exit(1); | ||
| } | ||
| const agentGlob = args[agentFilesIdx + 1]; | ||
| const configPath = args[configPathIdx + 1]; | ||
| (async () => { | ||
| try { | ||
| // Resolve agent files from glob or comma-separated list | ||
| let agentFiles; | ||
| if (agentGlob.includes('*')) { | ||
| // Use fs.readdir-based simple glob for *.md in a directory | ||
| const dir = path.dirname(agentGlob); | ||
| const entries = await fs.readdir(dir); | ||
| agentFiles = entries | ||
| .filter(e => e.endsWith('.md')) | ||
| .map(e => path.join(dir, e)); | ||
| } else { | ||
| agentFiles = agentGlob.split(',').map(f => f.trim()); | ||
| } | ||
| // Derive module context from config path | ||
| const moduleDir = path.dirname(configPath); | ||
| const modulePath = path.relative(path.resolve(moduleDir, '../../'), moduleDir); | ||
| const result = await validateActivation(agentFiles, { | ||
| configPath: configPath, | ||
| modulePath: modulePath, | ||
| moduleDir: moduleDir | ||
| }); | ||
| console.log(JSON.stringify(result, null, 2)); | ||
| process.exit(result.valid ? 0 : 1); | ||
| } catch (err) { | ||
| console.log(JSON.stringify({ valid: false, results: [], errors: [err.message] }, null, 2)); | ||
| process.exit(1); | ||
| } | ||
| })(); | ||
| } | ||
| module.exports = { validateActivation, validateSingleAgent, ACTIVATION_REGEX }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const yaml = require('js-yaml'); | ||
| const YAML = require('yaml'); // Comment-preserving YAML library (ag-7-1: I10). Used for the WRITE round-trip; js-yaml stays for the post-write read-back validation. | ||
| const { checkDirtyTree } = require('./registry-writer'); | ||
| /** @typedef {import('../types/factory-types').CreatorResult} CreatorResult */ | ||
| /** | ||
| * Append a new agent ID to an existing team's config.yaml. | ||
| * Enhanced Simple safety: read → validate → write (.tmp) → verify parse → rename. | ||
| * | ||
| * @param {string} newAgentId - Agent ID to add (e.g., "gamma-guardian") | ||
| * @param {string} configPath - Absolute path to existing config.yaml | ||
| * @param {Object} [options] | ||
| * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests) | ||
| * @returns {Promise<CreatorResult>} | ||
| */ | ||
| async function appendConfigAgent(newAgentId, configPath, options = {}) { | ||
| if (!newAgentId || !newAgentId.trim()) { | ||
| return { success: false, filePath: configPath, errors: ['newAgentId is required'] }; | ||
| } | ||
| // --- Read existing config --- | ||
| if (!await fs.pathExists(configPath)) { | ||
| return { success: false, filePath: configPath, errors: ['config.yaml does not exist at target path'] }; | ||
| } | ||
| // --- Dirty-tree detection (per-write) --- | ||
| if (!options.skipDirtyCheck) { | ||
| const dirtyResult = checkDirtyTree(configPath); | ||
| if (dirtyResult.dirty) { | ||
| return { success: false, filePath: configPath, errors: [], dirty: true, diff: dirtyResult.diff }; | ||
| } | ||
| } | ||
| let content; | ||
| try { | ||
| content = await fs.readFile(configPath, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, filePath: configPath, errors: [`Cannot read config: ${err.message}`] }; | ||
| } | ||
| // --- Parse and validate (ag-7-1: I10 — use comment-preserving YAML.parseDocument) --- | ||
| // Note: YAML.parseDocument does NOT throw on syntax errors; it returns a Document | ||
| // with .errors populated. Check both throw AND doc.errors to match js-yaml semantics. | ||
| let doc; | ||
| let config; | ||
| try { | ||
| doc = YAML.parseDocument(content); | ||
| if (doc.errors && doc.errors.length > 0) { | ||
| return { success: false, filePath: configPath, errors: [`Cannot parse config YAML: ${doc.errors[0].message}`] }; | ||
| } | ||
| config = doc.toJSON(); | ||
| } catch (err) { | ||
| return { success: false, filePath: configPath, errors: [`Cannot parse config YAML: ${err.message}`] }; | ||
| } | ||
| if (!config || !Array.isArray(config.agents)) { | ||
| return { success: false, filePath: configPath, errors: ['config.yaml missing agents array'] }; | ||
| } | ||
| // --- Duplicate check (additive-only) --- | ||
| if (config.agents.includes(newAgentId)) { | ||
| return { success: true, filePath: configPath, errors: [], skipped: 'agent already in config' }; | ||
| } | ||
| // --- Append (mutate via Document API to preserve comments) --- | ||
| const agentsNode = doc.get('agents'); | ||
| if (agentsNode && typeof agentsNode.add === 'function') { | ||
| agentsNode.add(newAgentId); | ||
| } else { | ||
| // Fallback: create a new sequence with the appended item | ||
| doc.set('agents', [...config.agents, newAgentId]); | ||
| } | ||
| // --- Atomic write (.tmp → validate → rename) --- | ||
| const tmpPath = configPath + '.tmp'; | ||
| try { | ||
| const newContent = doc.toString({ lineWidth: 0 }); | ||
| await fs.writeFile(tmpPath, newContent, 'utf8'); | ||
| // Verify parse of tmp file (ag-7-1 Task 5.4: read-back stays on js-yaml — checks structure only, not comments) | ||
| const readBack = yaml.load(await fs.readFile(tmpPath, 'utf8')); | ||
| if (!readBack || !Array.isArray(readBack.agents) || !readBack.agents.includes(newAgentId)) { | ||
| await fs.remove(tmpPath); | ||
| return { success: false, filePath: configPath, errors: ['Post-write verification failed: new agent not found in parsed output'] }; | ||
| } | ||
| // Rename tmp → target | ||
| await fs.rename(tmpPath, configPath); | ||
| } catch (err) { | ||
| await fs.remove(tmpPath).catch(() => {}); | ||
| return { success: false, filePath: configPath, errors: [`Write failed: ${err.message}`] }; | ||
| } | ||
| return { success: true, filePath: configPath, errors: [] }; | ||
| } | ||
| /** | ||
| * Append a new workflow name to an existing team's config.yaml. | ||
| * Enhanced Simple safety: read → validate → write (.tmp) → verify parse → rename. | ||
| * | ||
| * @param {string} newWorkflowName - Workflow name to add (kebab-case, e.g., "data-analysis") | ||
| * @param {string} configPath - Absolute path to existing config.yaml | ||
| * @param {Object} [options] | ||
| * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests) | ||
| * @returns {Promise<CreatorResult>} | ||
| */ | ||
| async function appendConfigWorkflow(newWorkflowName, configPath, options = {}) { | ||
| if (!newWorkflowName || !newWorkflowName.trim()) { | ||
| return { success: false, filePath: configPath, errors: ['newWorkflowName is required'] }; | ||
| } | ||
| // --- Read existing config --- | ||
| if (!await fs.pathExists(configPath)) { | ||
| return { success: false, filePath: configPath, errors: ['config.yaml does not exist at target path'] }; | ||
| } | ||
| // --- Dirty-tree detection (per-write) --- | ||
| if (!options.skipDirtyCheck) { | ||
| const dirtyResult = checkDirtyTree(configPath); | ||
| if (dirtyResult.dirty) { | ||
| return { success: false, filePath: configPath, errors: [], dirty: true, diff: dirtyResult.diff }; | ||
| } | ||
| } | ||
| let content; | ||
| try { | ||
| content = await fs.readFile(configPath, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, filePath: configPath, errors: [`Cannot read config: ${err.message}`] }; | ||
| } | ||
| // --- Parse and validate (ag-7-1: I10 — use comment-preserving YAML.parseDocument) --- | ||
| // Note: YAML.parseDocument does NOT throw on syntax errors; it returns a Document | ||
| // with .errors populated. Check both throw AND doc.errors to match js-yaml semantics. | ||
| let doc; | ||
| let config; | ||
| try { | ||
| doc = YAML.parseDocument(content); | ||
| if (doc.errors && doc.errors.length > 0) { | ||
| return { success: false, filePath: configPath, errors: [`Cannot parse config YAML: ${doc.errors[0].message}`] }; | ||
| } | ||
| config = doc.toJSON(); | ||
| } catch (err) { | ||
| return { success: false, filePath: configPath, errors: [`Cannot parse config YAML: ${err.message}`] }; | ||
| } | ||
| if (!config || !Array.isArray(config.workflows)) { | ||
| return { success: false, filePath: configPath, errors: ['config.yaml missing workflows array'] }; | ||
| } | ||
| // --- Duplicate check (additive-only) --- | ||
| if (config.workflows.includes(newWorkflowName)) { | ||
| return { success: true, filePath: configPath, errors: [], skipped: 'workflow already in config' }; | ||
| } | ||
| // --- Append (mutate via Document API to preserve comments) --- | ||
| const workflowsNode = doc.get('workflows'); | ||
| if (workflowsNode && typeof workflowsNode.add === 'function') { | ||
| workflowsNode.add(newWorkflowName); | ||
| } else { | ||
| doc.set('workflows', [...config.workflows, newWorkflowName]); | ||
| } | ||
| // --- Atomic write (.tmp → validate → rename) --- | ||
| const tmpPath = configPath + '.tmp'; | ||
| try { | ||
| const newContent = doc.toString({ lineWidth: 0 }); | ||
| await fs.writeFile(tmpPath, newContent, 'utf8'); | ||
| // Verify parse of tmp file (ag-7-1 Task 5.4: read-back stays on js-yaml — checks structure only, not comments) | ||
| const readBack = yaml.load(await fs.readFile(tmpPath, 'utf8')); | ||
| if (!readBack || !Array.isArray(readBack.workflows) || !readBack.workflows.includes(newWorkflowName)) { | ||
| await fs.remove(tmpPath); | ||
| return { success: false, filePath: configPath, errors: ['Post-write verification failed: new workflow not found in parsed output'] }; | ||
| } | ||
| // Rename tmp → target | ||
| await fs.rename(tmpPath, configPath); | ||
| } catch (err) { | ||
| await fs.remove(tmpPath).catch(() => {}); | ||
| return { success: false, filePath: configPath, errors: [`Write failed: ${err.message}`] }; | ||
| } | ||
| return { success: true, filePath: configPath, errors: [] }; | ||
| } | ||
| module.exports = { appendConfigAgent, appendConfigWorkflow }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const yaml = require('js-yaml'); | ||
| const { toKebab, deriveWorkflowName } = require('../utils/naming-utils'); | ||
| /** @typedef {import('../types/factory-types')} Types */ | ||
| /** | ||
| * Create a per-module config.yaml for a new team. | ||
| * Matches the Gyre/Vortex config.yaml schema exactly. | ||
| * | ||
| * Safety: Simple (write → verify parse). Additive-only (NFR17). | ||
| * Collision detection runs before writing (NFR15). | ||
| * | ||
| * @param {Object} specData - Parsed team spec file content | ||
| * @param {string} outputPath - Full path to write config.yaml | ||
| * @param {string} bmeRoot - Path to _bmad/bme/ directory for collision scanning | ||
| * @returns {Promise<import('../types/factory-types').CreatorResult>} | ||
| */ | ||
| async function createConfig(specData, outputPath, bmeRoot) { | ||
| const errors = []; | ||
| // --- Additive-only check (NFR17) --- | ||
| if (await fs.pathExists(outputPath)) { | ||
| return { success: false, filePath: outputPath, errors: ['config.yaml already exists at target path — additive-only, will not overwrite'], collisions: [] }; | ||
| } | ||
| // --- Collision detection (NFR15) --- | ||
| const collisions = await detectCollisions(specData, bmeRoot); | ||
| if (collisions.length > 0) { | ||
| return { success: false, filePath: outputPath, errors: ['Collision(s) detected with existing modules'], collisions }; | ||
| } | ||
| // --- Build config data --- | ||
| const configData = buildConfigData(specData); | ||
| // --- Write config.yaml --- | ||
| try { | ||
| await fs.ensureDir(path.dirname(outputPath)); | ||
| const content = yaml.dump(configData, { indent: 2, lineWidth: -1, noRefs: true }); | ||
| await fs.writeFile(outputPath, content, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, filePath: outputPath, errors: [`Write failed: ${err.message}`], collisions: [] }; | ||
| } | ||
| // --- Verify parse (Simple safety) --- | ||
| try { | ||
| const readBack = await fs.readFile(outputPath, 'utf8'); | ||
| const parsed = yaml.load(readBack); | ||
| if (!parsed || !parsed.submodule_name || !parsed.agents || !parsed.workflows) { | ||
| errors.push('Verification failed: config.yaml missing required fields after write'); | ||
| } | ||
| } catch (err) { | ||
| errors.push(`Verification failed: ${err.message}`); | ||
| } | ||
| if (errors.length > 0) { | ||
| return { success: false, filePath: outputPath, errors, collisions: [] }; | ||
| } | ||
| return { success: true, filePath: outputPath, errors: [], collisions: [] }; | ||
| } | ||
| /** | ||
| * Build config.yaml data matching Gyre/Vortex schema. | ||
| * @param {Object} specData - Parsed team spec file | ||
| * @returns {import('../types/factory-types').ConfigData} | ||
| */ | ||
| function buildConfigData(specData) { | ||
| const agents = (specData.agents || []).map(a => a.id); | ||
| // Derive workflow names from agents' capabilities (first capability kebab-case) | ||
| // or use workflow_names if available in spec | ||
| const workflows = deriveWorkflowNames(specData); | ||
| return { | ||
| submodule_name: `_${specData.team_name_kebab}`, | ||
| description: specData.description || `${specData.team_name} team module`, | ||
| module: 'bme', | ||
| output_folder: specData.integration.output_directory, | ||
| agents, | ||
| workflows, | ||
| version: '1.0.0', | ||
| user_name: '{user}', | ||
| communication_language: 'en', | ||
| party_mode_enabled: true, | ||
| core_module: 'bme' | ||
| }; | ||
| } | ||
| /** | ||
| * Derive workflow names from spec data. | ||
| * Uses shared deriveWorkflowName() for per-agent logic, then deduplicates. | ||
| * @param {Object} specData | ||
| * @returns {string[]} | ||
| */ | ||
| function deriveWorkflowNames(specData) { | ||
| const names = (specData.agents || []).map(agent => deriveWorkflowName(agent, specData)); | ||
| // Check for intra-spec duplicate workflow names | ||
| const seen = new Set(); | ||
| for (let i = 0; i < names.length; i++) { | ||
| if (seen.has(names[i])) { | ||
| const agent = (specData.agents || [])[i]; | ||
| // Disambiguate by appending agent id | ||
| names[i] = `${names[i]}-${(agent && agent.id) || i}`; | ||
| } | ||
| seen.add(names[i]); | ||
| } | ||
| return names; | ||
| } | ||
| /** | ||
| * Scan existing config.yaml files in bmeRoot for collisions (NFR15). | ||
| * Checks submodule_name, agent IDs, and workflow names. | ||
| * @param {Object} specData - New team spec | ||
| * @param {string} bmeRoot - Path to _bmad/bme/ | ||
| * @returns {Promise<import('../types/factory-types').Collision[]>} | ||
| */ | ||
| async function detectCollisions(specData, bmeRoot) { | ||
| const collisions = []; | ||
| const newSubmodule = `_${specData.team_name_kebab}`; | ||
| const newAgentIds = (specData.agents || []).map(a => a.id); | ||
| const newWorkflows = deriveWorkflowNames(specData); | ||
| // Find all existing config.yaml files under bmeRoot | ||
| let entries; | ||
| try { | ||
| entries = await fs.readdir(bmeRoot); | ||
| } catch { | ||
| return collisions; // bmeRoot doesn't exist yet — no collisions possible | ||
| } | ||
| for (const entry of entries) { | ||
| // Skip the new team's own directory to avoid self-collision on re-run | ||
| if (entry === newSubmodule) continue; | ||
| const configPath = path.join(bmeRoot, entry, 'config.yaml'); | ||
| if (!await fs.pathExists(configPath)) continue; | ||
| let existing; | ||
| try { | ||
| existing = yaml.load(await fs.readFile(configPath, 'utf8')); | ||
| } catch { | ||
| continue; // Skip unparseable configs | ||
| } | ||
| if (!existing) continue; | ||
| const moduleName = entry; | ||
| // Check submodule_name collision | ||
| if (existing.submodule_name === newSubmodule) { | ||
| collisions.push({ field: 'submodule_name', value: newSubmodule, existingModule: moduleName }); | ||
| } | ||
| // Check agent ID collisions | ||
| const existingAgents = existing.agents || []; | ||
| for (const agentId of newAgentIds) { | ||
| if (existingAgents.includes(agentId)) { | ||
| collisions.push({ field: 'agent', value: agentId, existingModule: moduleName }); | ||
| } | ||
| } | ||
| // Check workflow name collisions | ||
| const existingWorkflows = existing.workflows || []; | ||
| for (const wf of newWorkflows) { | ||
| if (existingWorkflows.includes(wf)) { | ||
| collisions.push({ field: 'workflow', value: wf, existingModule: moduleName }); | ||
| } | ||
| } | ||
| } | ||
| return collisions; | ||
| } | ||
| // --- CLI entry point --- | ||
| if (require.main === module) { | ||
| const args = process.argv.slice(2); | ||
| const specFileIdx = args.indexOf('--spec-file'); | ||
| const dryRunIdx = args.indexOf('--dry-run'); | ||
| if (specFileIdx === -1 || !args[specFileIdx + 1]) { | ||
| console.error('Usage: node config-creator.js --spec-file <path> [--dry-run]'); | ||
| process.exit(1); | ||
| } | ||
| const specFilePath = args[specFileIdx + 1]; | ||
| const dryRun = dryRunIdx !== -1; | ||
| (async () => { | ||
| try { | ||
| const specContent = await fs.readFile(specFilePath, 'utf8'); | ||
| const specData = yaml.load(specContent); | ||
| const bmeRoot = path.resolve(__dirname, '../../../../'); | ||
| const outputPath = path.join(bmeRoot, `_${specData.team_name_kebab}`, 'config.yaml'); | ||
| if (dryRun) { | ||
| const collisions = await detectCollisions(specData, bmeRoot); | ||
| console.log(JSON.stringify({ dryRun: true, collisions }, null, 2)); | ||
| process.exit(collisions.length > 0 ? 1 : 0); | ||
| } | ||
| const result = await createConfig(specData, outputPath, bmeRoot); | ||
| console.log(JSON.stringify(result, null, 2)); | ||
| process.exit(result.success ? 0 : 1); | ||
| } catch (err) { | ||
| console.log(JSON.stringify({ success: false, errors: [err.message] }, null, 2)); | ||
| process.exit(1); | ||
| } | ||
| })(); | ||
| } | ||
| module.exports = { createConfig, buildConfigData, detectCollisions, deriveWorkflowNames, toKebab }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const { CSV_HEADER, formatCsvRow, csvQuote, deriveCode, toTitleCase } = require('./csv-creator'); | ||
| const { checkDirtyTree } = require('./registry-writer'); | ||
| /** @typedef {import('../types/factory-types').CreatorResult} CreatorResult */ | ||
| /** | ||
| * Append a new row to an existing team's module-help.csv. | ||
| * Enhanced Simple safety: read → validate header → append → write (.tmp) → verify → rename. | ||
| * | ||
| * @param {Object} rowData - Data for the new CSV row | ||
| * @param {string} rowData.module - Module path (e.g., "bme/_gyre") | ||
| * @param {string} rowData.workflowName - Workflow name (kebab-case) | ||
| * @param {string} rowData.agentId - Agent ID | ||
| * @param {string} rowData.agentRole - Agent role description | ||
| * @param {string} rowData.teamNameKebab - Team name for command derivation | ||
| * @param {string} rowData.outputLocation - Output directory | ||
| * @param {number} [rowData.sequence] - Optional sequence number (auto-derived if omitted) | ||
| * @param {string} csvPath - Absolute path to existing module-help.csv | ||
| * @param {Object} [options] | ||
| * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests) | ||
| * @returns {Promise<CreatorResult>} | ||
| */ | ||
| async function appendCsvRow(rowData, csvPath, options = {}) { | ||
| if (!rowData || !rowData.agentId) { | ||
| return { success: false, filePath: csvPath, rowCount: 0, errors: ['rowData with agentId is required'] }; | ||
| } | ||
| // --- Read existing CSV --- | ||
| if (!await fs.pathExists(csvPath)) { | ||
| return { success: false, filePath: csvPath, rowCount: 0, errors: ['module-help.csv does not exist at target path'] }; | ||
| } | ||
| // --- Dirty-tree detection (per-write) --- | ||
| if (!options.skipDirtyCheck) { | ||
| const dirtyResult = checkDirtyTree(csvPath); | ||
| if (dirtyResult.dirty) { | ||
| return { success: false, filePath: csvPath, rowCount: 0, errors: [], dirty: true, diff: dirtyResult.diff }; | ||
| } | ||
| } | ||
| let content; | ||
| try { | ||
| content = await fs.readFile(csvPath, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, filePath: csvPath, rowCount: 0, errors: [`Cannot read CSV: ${err.message}`] }; | ||
| } | ||
| const lines = content.trim().split('\n'); | ||
| // --- Validate header --- | ||
| if (lines[0] !== CSV_HEADER) { | ||
| return { success: false, filePath: csvPath, rowCount: 0, errors: ['CSV header mismatch — file format not recognized'] }; | ||
| } | ||
| // --- Duplicate check --- | ||
| const existingRows = lines.slice(1); | ||
| for (const row of existingRows) { | ||
| const cols = row.split(','); | ||
| // Column 8 (index 8) is agent | ||
| if (cols[8] === rowData.agentId) { | ||
| return { success: true, filePath: csvPath, rowCount: existingRows.length, errors: [], skipped: 'agent row already exists' }; | ||
| } | ||
| } | ||
| // --- Build new row --- | ||
| const sequence = rowData.sequence || ((existingRows.length + 1) * 10); | ||
| const csvRow = { | ||
| module: rowData.module, | ||
| phase: 'anytime', | ||
| name: toTitleCase(rowData.workflowName), | ||
| code: deriveCode(rowData.workflowName), | ||
| sequence, | ||
| workflow_file: `_bmad/bme/_${rowData.teamNameKebab}/workflows/${rowData.workflowName}/workflow.md`, | ||
| command: `bmad-${rowData.teamNameKebab}-${rowData.workflowName}`, | ||
| required: 'false', | ||
| agent: rowData.agentId, | ||
| options: 'Create Mode', | ||
| description: rowData.agentRole, | ||
| output_location: rowData.outputLocation, | ||
| outputs: rowData.workflowName, | ||
| }; | ||
| const newLine = formatCsvRow(csvRow); | ||
| // --- Atomic write (.tmp → verify → rename) --- | ||
| const tmpPath = csvPath + '.tmp'; | ||
| try { | ||
| const newContent = content.trimEnd() + '\n' + newLine + '\n'; | ||
| await fs.writeFile(tmpPath, newContent, 'utf8'); | ||
| // Verify header preserved | ||
| const readBack = await fs.readFile(tmpPath, 'utf8'); | ||
| const readLines = readBack.trim().split('\n'); | ||
| if (readLines[0] !== CSV_HEADER) { | ||
| await fs.remove(tmpPath); | ||
| return { success: false, filePath: csvPath, rowCount: 0, errors: ['Post-write verification failed: header mismatch'] }; | ||
| } | ||
| // Verify row count increased | ||
| if (readLines.length <= lines.length) { | ||
| await fs.remove(tmpPath); | ||
| return { success: false, filePath: csvPath, rowCount: 0, errors: ['Post-write verification failed: row count did not increase'] }; | ||
| } | ||
| await fs.rename(tmpPath, csvPath); | ||
| } catch (err) { | ||
| await fs.remove(tmpPath).catch(() => {}); | ||
| return { success: false, filePath: csvPath, rowCount: 0, errors: [`Write failed: ${err.message}`] }; | ||
| } | ||
| return { success: true, filePath: csvPath, rowCount: existingRows.length + 1, errors: [] }; | ||
| } | ||
| module.exports = { appendCsvRow }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const yaml = require('js-yaml'); | ||
| const { deriveWorkflowNames, toKebab } = require('./config-creator'); | ||
| /** @typedef {import('../types/factory-types')} Types */ | ||
| /** | ||
| * Standard module-help.csv header — matches BMB and BMM exactly. | ||
| * Trailing comma is intentional (existing pattern). | ||
| */ | ||
| const CSV_HEADER = 'module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs,'; | ||
| /** | ||
| * Create a per-module module-help.csv for a new team. | ||
| * One row per workflow, matching the BMB/BMM column format. | ||
| * | ||
| * Safety: Simple (write → verify header match). Additive-only (NFR17). | ||
| * | ||
| * @param {Object} specData - Parsed team spec file content | ||
| * @param {string} outputPath - Full path to write module-help.csv | ||
| * @returns {Promise<import('../types/factory-types').CreatorResult>} | ||
| */ | ||
| async function createCsv(specData, outputPath) { | ||
| // --- Additive-only check (NFR17) --- | ||
| if (await fs.pathExists(outputPath)) { | ||
| return { success: false, filePath: outputPath, rowCount: 0, errors: ['module-help.csv already exists at target path — additive-only, will not overwrite'] }; | ||
| } | ||
| // --- Build CSV rows --- | ||
| const rows = buildCsvRows(specData); | ||
| // --- Write CSV --- | ||
| try { | ||
| await fs.ensureDir(path.dirname(outputPath)); | ||
| const content = CSV_HEADER + '\n' + rows.map(formatCsvRow).join('\n') + '\n'; | ||
| await fs.writeFile(outputPath, content, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, filePath: outputPath, rowCount: 0, errors: [`Write failed: ${err.message}`] }; | ||
| } | ||
| // --- Verify header match (Simple safety) --- | ||
| try { | ||
| const readBack = await fs.readFile(outputPath, 'utf8'); | ||
| const firstLine = readBack.split('\n')[0]; | ||
| if (firstLine !== CSV_HEADER) { | ||
| return { success: false, filePath: outputPath, rowCount: rows.length, errors: ['Verification failed: header mismatch after write'] }; | ||
| } | ||
| } catch (err) { | ||
| return { success: false, filePath: outputPath, rowCount: 0, errors: [`Verification failed: ${err.message}`] }; | ||
| } | ||
| return { success: true, filePath: outputPath, rowCount: rows.length, errors: [] }; | ||
| } | ||
| /** | ||
| * Build CSV row data from spec. | ||
| * One row per workflow (one workflow per agent). | ||
| * @param {Object} specData | ||
| * @returns {import('../types/factory-types').CsvRow[]} | ||
| */ | ||
| function buildCsvRows(specData) { | ||
| const modulePath = `bme/_${specData.team_name_kebab}`; | ||
| const outputLocation = specData.integration.output_directory; | ||
| const workflows = deriveWorkflowNames(specData); | ||
| const rows = []; | ||
| for (let i = 0; i < (specData.agents || []).length; i++) { | ||
| const agent = specData.agents[i]; | ||
| const workflowName = workflows[i]; | ||
| const displayName = toTitleCase(workflowName); | ||
| const code = deriveCode(workflowName); | ||
| const sequence = (i + 1) * 10; | ||
| rows.push({ | ||
| module: modulePath, | ||
| phase: 'anytime', | ||
| name: displayName, | ||
| code, | ||
| sequence, | ||
| workflow_file: `_bmad/bme/_${specData.team_name_kebab}/workflows/${workflowName}/workflow.md`, | ||
| command: `bmad-${specData.team_name_kebab}-${workflowName}`, | ||
| required: 'false', | ||
| agent: agent.id, | ||
| options: 'Create Mode', | ||
| description: agent.role, | ||
| output_location: outputLocation, | ||
| outputs: workflowName | ||
| }); | ||
| } | ||
| return rows; | ||
| } | ||
| /** | ||
| * Format a CsvRow into a CSV line string. | ||
| * Matches BMB/BMM format: values with commas/spaces are quoted. | ||
| * Trailing comma included. | ||
| * @param {import('../types/factory-types').CsvRow} row | ||
| * @returns {string} | ||
| */ | ||
| function formatCsvRow(row) { | ||
| const values = [ | ||
| row.module, | ||
| row.phase, | ||
| row.name, | ||
| row.code, | ||
| row.sequence, | ||
| row.workflow_file, | ||
| row.command, | ||
| row.required, | ||
| row.agent, | ||
| row.options, | ||
| csvQuote(row.description), | ||
| row.output_location, | ||
| csvQuote(row.outputs) | ||
| ]; | ||
| return values.join(',') + ','; | ||
| } | ||
| /** | ||
| * Quote a CSV value if it contains commas, quotes, or newlines. | ||
| * @param {string} val | ||
| * @returns {string} | ||
| */ | ||
| function csvQuote(val) { | ||
| if (val == null || val === '') return ''; | ||
| if (val.includes(',') || val.includes('"') || val.includes('\n')) { | ||
| return `"${val.replace(/"/g, '""')}"`; | ||
| } | ||
| return val; | ||
| } | ||
| /** | ||
| * Derive a 2-letter uppercase code from a workflow name. | ||
| * Takes first letter of first two words (e.g., "stack-detection" → "SD"). | ||
| * @param {string} name - kebab-case workflow name | ||
| * @returns {string} | ||
| */ | ||
| function deriveCode(name) { | ||
| const words = name.split('-').filter(Boolean); | ||
| if (words.length >= 2) { | ||
| return (words[0][0] + words[1][0]).toUpperCase(); | ||
| } | ||
| return (words[0] || 'XX').substring(0, 2).toUpperCase(); | ||
| } | ||
| /** | ||
| * Convert kebab-case to Title Case. | ||
| * @param {string} str | ||
| * @returns {string} | ||
| */ | ||
| function toTitleCase(str) { | ||
| return str | ||
| .split('-') | ||
| .map(word => word.charAt(0).toUpperCase() + word.slice(1)) | ||
| .join(' '); | ||
| } | ||
| // --- CLI entry point --- | ||
| if (require.main === module) { | ||
| const args = process.argv.slice(2); | ||
| const specFileIdx = args.indexOf('--spec-file'); | ||
| if (specFileIdx === -1 || !args[specFileIdx + 1]) { | ||
| console.error('Usage: node csv-creator.js --spec-file <path>'); | ||
| process.exit(1); | ||
| } | ||
| const specFilePath = args[specFileIdx + 1]; | ||
| (async () => { | ||
| try { | ||
| const specContent = await fs.readFile(specFilePath, 'utf8'); | ||
| const specData = yaml.load(specContent); | ||
| const bmeRoot = path.resolve(__dirname, '../../../../'); | ||
| const outputPath = path.join(bmeRoot, `_${specData.team_name_kebab}`, 'module-help.csv'); | ||
| const result = await createCsv(specData, outputPath); | ||
| console.log(JSON.stringify(result, null, 2)); | ||
| process.exit(result.success ? 0 : 1); | ||
| } catch (err) { | ||
| console.log(JSON.stringify({ success: false, errors: [err.message] }, null, 2)); | ||
| process.exit(1); | ||
| } | ||
| })(); | ||
| } | ||
| module.exports = { createCsv, buildCsvRows, formatCsvRow, csvQuote, deriveCode, toTitleCase, CSV_HEADER }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const { | ||
| derivePrefix, | ||
| buildAgentEntry, | ||
| escapeSingleQuotes, | ||
| verifyRequire, | ||
| checkDirtyTree, | ||
| } = require('./registry-writer'); | ||
| /** @typedef {import('../types/factory-types').RegistryResult} RegistryResult */ | ||
| /** | ||
| * Append a new agent to an existing team's module block in agent-registry.js. | ||
| * Uses Full Write Safety Protocol: stage → validate → check → apply → verify → rollback. | ||
| * | ||
| * @param {string} teamNameKebab - Existing team (e.g., "gyre") | ||
| * @param {Object} newAgentData - Agent spec with id, name, icon, role, persona fields | ||
| * @param {string} registryPath - Absolute path to agent-registry.js | ||
| * @param {Object} [options] | ||
| * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests) | ||
| * @returns {Promise<RegistryResult>} | ||
| */ | ||
| async function appendAgentToBlock(teamNameKebab, newAgentData, registryPath, options = {}) { | ||
| if (!teamNameKebab || !teamNameKebab.trim()) { | ||
| return fail(['teamNameKebab is required and must not be empty']); | ||
| } | ||
| if (!newAgentData || !newAgentData.id) { | ||
| return fail(['newAgentData with id is required']); | ||
| } | ||
| const prefix = derivePrefix(teamNameKebab); | ||
| // --- Read current content --- | ||
| let currentContent; | ||
| try { | ||
| currentContent = await fs.readFile(registryPath, 'utf8'); | ||
| } catch (err) { | ||
| return fail([`Cannot read registry file: ${err.message}`]); | ||
| } | ||
| // --- 1. STAGE: Locate existing block and build new entry --- | ||
| const agentsVarName = `${prefix}_AGENTS`; | ||
| if (!currentContent.includes(`const ${agentsVarName}`)) { | ||
| return fail([`Team block not found: const ${agentsVarName} does not exist in registry`]); | ||
| } | ||
| // Check duplicate agent ID | ||
| const agentIdLiteral = `id: '${escapeSingleQuotes(newAgentData.id)}'`; | ||
| if (currentContent.includes(agentIdLiteral)) { | ||
| return { success: true, written: [], skipped: ['agent already exists in block'], errors: [], rollbackApplied: false }; | ||
| } | ||
| // Build the new agent entry as JS text | ||
| const entry = buildAgentEntry(newAgentData, teamNameKebab); | ||
| const entryLines = formatAgentEntry(entry); | ||
| // --- 2. VALIDATE: Structural checks --- | ||
| // Find the closing ]; of the AGENTS array | ||
| const arrayStart = currentContent.indexOf(`const ${agentsVarName} = [`); | ||
| if (arrayStart === -1) { | ||
| return fail([`Cannot parse ${agentsVarName} array start`]); | ||
| } | ||
| const closingBracket = findArrayClose(currentContent, arrayStart); | ||
| if (closingBracket === -1) { | ||
| return fail([`Cannot find closing ]; for ${agentsVarName}`]); | ||
| } | ||
| // --- 3. CHECK: Dirty-tree detection --- | ||
| if (!options.skipDirtyCheck) { | ||
| const dirtyResult = checkDirtyTree(registryPath); | ||
| if (dirtyResult.dirty) { | ||
| return { success: false, written: [], skipped: [], errors: [], rollbackApplied: false, dirty: true, diff: dirtyResult.diff }; | ||
| } | ||
| } | ||
| // --- 4. APPLY: Insert new entry before closing ]; --- | ||
| const bakPath = `${registryPath}.bak`; | ||
| if (await fs.pathExists(bakPath)) { | ||
| return fail(['Stale .bak file exists — a previous run may have crashed. Remove it manually before retrying.']); | ||
| } | ||
| try { | ||
| await fs.writeFile(bakPath, currentContent, 'utf8'); | ||
| } catch (err) { | ||
| return fail([`Failed to create backup: ${err.message}`]); | ||
| } | ||
| // Insert the new entry before the closing ]; | ||
| const before = currentContent.slice(0, closingBracket); | ||
| const after = currentContent.slice(closingBracket); | ||
| const modified = before + entryLines + '\n' + after; | ||
| try { | ||
| await fs.writeFile(registryPath, modified, 'utf8'); | ||
| } catch (err) { | ||
| await fs.writeFile(registryPath, currentContent, 'utf8'); | ||
| await fs.remove(bakPath); | ||
| return { success: false, written: [], skipped: [], errors: [`Write failed: ${err.message}`], rollbackApplied: true }; | ||
| } | ||
| // --- 5. VERIFY: Re-read + node require() --- | ||
| const verifyError = verifyRequire(registryPath); | ||
| if (verifyError) { | ||
| await fs.writeFile(registryPath, currentContent, 'utf8'); | ||
| await fs.remove(bakPath); | ||
| return { success: false, written: [], skipped: [], errors: [verifyError], rollbackApplied: true }; | ||
| } | ||
| // Verify new agent appears in the file | ||
| const verifyContent = await fs.readFile(registryPath, 'utf8'); | ||
| if (!verifyContent.includes(agentIdLiteral)) { | ||
| await fs.writeFile(registryPath, currentContent, 'utf8'); | ||
| await fs.remove(bakPath); | ||
| return { success: false, written: [], skipped: [], errors: ['Post-write verification: new agent entry not found'], rollbackApplied: true }; | ||
| } | ||
| // --- Cleanup --- | ||
| await fs.remove(bakPath); | ||
| return { | ||
| success: true, | ||
| written: [newAgentData.id], | ||
| skipped: [], | ||
| errors: [], | ||
| rollbackApplied: false, | ||
| }; | ||
| } | ||
| /** | ||
| * Format an agent entry object as JS source text for insertion into AGENTS array. | ||
| * @param {Object} entry - From buildAgentEntry | ||
| * @returns {string} | ||
| */ | ||
| function formatAgentEntry(entry) { | ||
| const lines = []; | ||
| lines.push(' {'); | ||
| lines.push(` id: '${escapeSingleQuotes(entry.id)}', name: '${escapeSingleQuotes(entry.name)}', icon: '${entry.icon}',`); | ||
| lines.push(` title: '${escapeSingleQuotes(entry.title)}', stream: '${escapeSingleQuotes(entry.stream)}',`); | ||
| lines.push(' persona: {'); | ||
| lines.push(` role: '${escapeSingleQuotes(entry.persona.role)}',`); | ||
| lines.push(` identity: '${escapeSingleQuotes(entry.persona.identity)}',`); | ||
| lines.push(` communication_style: '${escapeSingleQuotes(entry.persona.communication_style)}',`); | ||
| lines.push(` expertise: '${escapeSingleQuotes(entry.persona.expertise)}',`); | ||
| lines.push(' },'); | ||
| lines.push(' },'); | ||
| return lines.join('\n'); | ||
| } | ||
| /** | ||
| * Find the closing ]; of an array that starts with `const NAME = [`. | ||
| * Counts bracket depth to handle nested objects. | ||
| * @param {string} content | ||
| * @param {number} startIdx - Index of `const NAME = [` | ||
| * @returns {number} Index of the `]` in `];`, or -1 | ||
| */ | ||
| function findArrayClose(content, startIdx) { | ||
| const bracketOpen = content.indexOf('[', startIdx); | ||
| if (bracketOpen === -1) return -1; | ||
| let depth = 0; | ||
| let inString = false; | ||
| let stringChar = ''; | ||
| let escaped = false; | ||
| for (let i = bracketOpen; i < content.length; i++) { | ||
| const ch = content[i]; | ||
| if (escaped) { | ||
| escaped = false; | ||
| continue; | ||
| } | ||
| if (ch === '\\') { | ||
| escaped = true; | ||
| continue; | ||
| } | ||
| if (inString) { | ||
| if (ch === stringChar) { | ||
| inString = false; | ||
| } | ||
| continue; | ||
| } | ||
| // Skip line comments | ||
| if (ch === '/' && content[i + 1] === '/') { | ||
| const eol = content.indexOf('\n', i); | ||
| i = eol === -1 ? content.length : eol; | ||
| continue; | ||
| } | ||
| // Skip block comments | ||
| if (ch === '/' && content[i + 1] === '*') { | ||
| const end = content.indexOf('*/', i + 2); | ||
| i = end === -1 ? content.length : end + 1; | ||
| continue; | ||
| } | ||
| if (ch === "'" || ch === '"' || ch === '`') { | ||
| inString = true; | ||
| stringChar = ch; | ||
| continue; | ||
| } | ||
| if (ch === '[') depth++; | ||
| if (ch === ']') { | ||
| depth--; | ||
| if (depth === 0) return i; | ||
| } | ||
| } | ||
| return -1; | ||
| } | ||
| /** | ||
| * Create a failure result. | ||
| * @param {string[]} errors | ||
| * @returns {RegistryResult} | ||
| */ | ||
| function fail(errors) { | ||
| return { success: false, written: [], skipped: [], errors, rollbackApplied: false }; | ||
| } | ||
| /** | ||
| * Append a new workflow entry to an existing team's WORKFLOWS array in agent-registry.js. | ||
| * Uses Full Write Safety Protocol: stage → validate → check → apply → verify → rollback. | ||
| * | ||
| * @param {string} teamNameKebab - Existing team (e.g., "gyre") | ||
| * @param {string} workflowName - Workflow name (kebab-case, e.g., "data-analysis") | ||
| * @param {string} agentId - Agent ID this workflow belongs to (e.g., "stack-detective") | ||
| * @param {string} registryPath - Absolute path to agent-registry.js | ||
| * @param {Object} [options] | ||
| * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests) | ||
| * @returns {Promise<RegistryResult>} | ||
| */ | ||
| async function appendWorkflowToBlock(teamNameKebab, workflowName, agentId, registryPath, options = {}) { | ||
| if (!teamNameKebab || !teamNameKebab.trim()) { | ||
| return fail(['teamNameKebab is required and must not be empty']); | ||
| } | ||
| if (!workflowName || !workflowName.trim()) { | ||
| return fail(['workflowName is required and must not be empty']); | ||
| } | ||
| if (!agentId || !agentId.trim()) { | ||
| return fail(['agentId is required and must not be empty']); | ||
| } | ||
| // Normalize inputs — trim whitespace to prevent phantom entries | ||
| teamNameKebab = teamNameKebab.trim(); | ||
| workflowName = workflowName.trim(); | ||
| agentId = agentId.trim(); | ||
| const prefix = derivePrefix(teamNameKebab); | ||
| // --- Read current content --- | ||
| let currentContent; | ||
| try { | ||
| currentContent = await fs.readFile(registryPath, 'utf8'); | ||
| } catch (err) { | ||
| return fail([`Cannot read registry file: ${err.message}`]); | ||
| } | ||
| // --- 1. STAGE: Locate existing block and build new entry --- | ||
| const workflowsVarName = `${prefix}_WORKFLOWS`; | ||
| if (!currentContent.includes(`const ${workflowsVarName}`)) { | ||
| return fail([`Team block not found: const ${workflowsVarName} does not exist in registry`]); | ||
| } | ||
| // --- 2. VALIDATE: Structural checks --- | ||
| const arrayStart = currentContent.indexOf(`const ${workflowsVarName} = [`); | ||
| if (arrayStart === -1) { | ||
| return fail([`Cannot parse ${workflowsVarName} array start`]); | ||
| } | ||
| const closingBracket = findArrayClose(currentContent, arrayStart); | ||
| if (closingBracket === -1) { | ||
| return fail([`Cannot find closing ]; for ${workflowsVarName}`]); | ||
| } | ||
| // Check duplicate workflow entry — scoped to THIS team's WORKFLOWS block only | ||
| const nameLiteral = `name: '${escapeSingleQuotes(workflowName)}'`; | ||
| const blockContent = currentContent.slice(arrayStart, closingBracket + 1); | ||
| if (blockContent.includes(nameLiteral)) { | ||
| return { success: true, written: [], skipped: ['workflow already exists in block'], errors: [], rollbackApplied: false }; | ||
| } | ||
| // --- 3. CHECK: Dirty-tree detection --- | ||
| if (!options.skipDirtyCheck) { | ||
| const dirtyResult = checkDirtyTree(registryPath); | ||
| if (dirtyResult.dirty) { | ||
| return { success: false, written: [], skipped: [], errors: [], rollbackApplied: false, dirty: true, diff: dirtyResult.diff }; | ||
| } | ||
| } | ||
| // --- 4. APPLY: Insert new entry before closing ]; --- | ||
| const bakPath = `${registryPath}.bak`; | ||
| if (await fs.pathExists(bakPath)) { | ||
| return fail(['Stale .bak file exists — a previous run may have crashed. Remove it manually before retrying.']); | ||
| } | ||
| try { | ||
| await fs.writeFile(bakPath, currentContent, 'utf8'); | ||
| } catch (err) { | ||
| return fail([`Failed to create backup: ${err.message}`]); | ||
| } | ||
| // Build workflow entry line | ||
| const entryLine = ` { name: '${escapeSingleQuotes(workflowName)}', agent: '${escapeSingleQuotes(agentId)}' },`; | ||
| const before = currentContent.slice(0, closingBracket); | ||
| const after = currentContent.slice(closingBracket); | ||
| const modified = before + entryLine + '\n' + after; | ||
| try { | ||
| await fs.writeFile(registryPath, modified, 'utf8'); | ||
| } catch (err) { | ||
| let rollbackApplied = false; | ||
| try { | ||
| await fs.writeFile(registryPath, currentContent, 'utf8'); | ||
| rollbackApplied = true; | ||
| } catch (rollbackErr) { | ||
| return { success: false, written: [], skipped: [], errors: [`Write failed: ${err.message}`, `Rollback also failed: ${rollbackErr.message}`], rollbackApplied: false }; | ||
| } | ||
| await fs.remove(bakPath).catch(() => {}); | ||
| return { success: false, written: [], skipped: [], errors: [`Write failed: ${err.message}`], rollbackApplied }; | ||
| } | ||
| // --- 5. VERIFY: Re-read + node require() --- | ||
| const verifyError = verifyRequire(registryPath); | ||
| if (verifyError) { | ||
| try { | ||
| await fs.writeFile(registryPath, currentContent, 'utf8'); | ||
| await fs.remove(bakPath); | ||
| } catch (rollbackErr) { | ||
| return { success: false, written: [], skipped: [], errors: [verifyError, `Rollback failed: ${rollbackErr.message}`], rollbackApplied: false }; | ||
| } | ||
| return { success: false, written: [], skipped: [], errors: [verifyError], rollbackApplied: true }; | ||
| } | ||
| // Verify new workflow appears in the file | ||
| const verifyContent = await fs.readFile(registryPath, 'utf8'); | ||
| if (!verifyContent.includes(nameLiteral)) { | ||
| try { | ||
| await fs.writeFile(registryPath, currentContent, 'utf8'); | ||
| await fs.remove(bakPath); | ||
| } catch (rollbackErr) { | ||
| return { success: false, written: [], skipped: [], errors: ['Post-write verification: new workflow entry not found', `Rollback failed: ${rollbackErr.message}`], rollbackApplied: false }; | ||
| } | ||
| return { success: false, written: [], skipped: [], errors: ['Post-write verification: new workflow entry not found'], rollbackApplied: true }; | ||
| } | ||
| // --- Cleanup --- | ||
| await fs.remove(bakPath); | ||
| return { | ||
| success: true, | ||
| written: [workflowName], | ||
| skipped: [], | ||
| errors: [], | ||
| rollbackApplied: false, | ||
| }; | ||
| } | ||
| module.exports = { | ||
| appendAgentToBlock, | ||
| appendWorkflowToBlock, | ||
| formatAgentEntry, | ||
| findArrayClose, | ||
| }; |
| 'use strict'; | ||
| const fs = require('fs-extra'); | ||
| const path = require('path'); | ||
| const { execSync } = require('child_process'); | ||
| const { toKebab, deriveWorkflowName } = require('../utils/naming-utils'); | ||
| /** @typedef {import('../types/factory-types')} Types */ | ||
| /** | ||
| * Write a new module block to agent-registry.js with Full Write Safety Protocol. | ||
| * | ||
| * Protocol: stage → validate → check → apply → verify → rollback | ||
| * | ||
| * This is the ONLY writer that uses the full protocol because agent-registry.js | ||
| * is a shared file consumed by refresh-installation, validator, convoke-doctor, | ||
| * installer, index.js, and migration-runner. | ||
| * | ||
| * @param {Object} specData - Parsed team spec with agents enriched with persona fields | ||
| * @param {string} registryPath - Absolute path to agent-registry.js | ||
| * @param {Object} [options] | ||
| * @param {boolean} [options.skipDirtyCheck] - Skip git dirty-tree detection (for tests) | ||
| * @returns {Promise<import('../types/factory-types').RegistryResult>} | ||
| */ | ||
| async function writeRegistryBlock(specData, registryPath, options = {}) { | ||
| if (!specData.team_name_kebab || !specData.team_name_kebab.trim()) { | ||
| return { success: false, written: [], skipped: [], errors: ['team_name_kebab is required and must not be empty'], rollbackApplied: false }; | ||
| } | ||
| const prefix = derivePrefix(specData.team_name_kebab); | ||
| const teamName = specData.team_name || specData.team_name_kebab; | ||
| // --- Idempotency check --- | ||
| let currentContent; | ||
| try { | ||
| currentContent = await fs.readFile(registryPath, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, written: [], skipped: [], errors: [`Cannot read registry file: ${err.message}`], rollbackApplied: false }; | ||
| } | ||
| if (currentContent.includes(`const ${prefix}_AGENTS`)) { | ||
| return { success: true, written: [], skipped: ['block already exists'], errors: [], rollbackApplied: false }; | ||
| } | ||
| // --- 1. STAGE: Build module block + export additions --- | ||
| const workflowNames = buildWorkflowNames(specData); | ||
| const moduleBlock = buildModuleBlock(specData, prefix, teamName, workflowNames); | ||
| const exportNames = buildExportNames(prefix); | ||
| // --- 2. VALIDATE: Syntax, prefix uniqueness, additive-only --- | ||
| const validateErrors = validateStaged(moduleBlock, prefix, currentContent); | ||
| if (validateErrors.length > 0) { | ||
| return { success: false, written: [], skipped: [], errors: validateErrors, rollbackApplied: false }; | ||
| } | ||
| // Validate staged block syntax via temp file | ||
| const syntaxError = await validateSyntax(moduleBlock, prefix); | ||
| if (syntaxError) { | ||
| return { success: false, written: [], skipped: [], errors: [syntaxError], rollbackApplied: false }; | ||
| } | ||
| // --- 3. CHECK: Dirty-tree detection --- | ||
| if (!options.skipDirtyCheck) { | ||
| const dirtyResult = checkDirtyTree(registryPath); | ||
| if (dirtyResult.dirty) { | ||
| return { success: false, written: [], skipped: [], errors: [], rollbackApplied: false, dirty: true, diff: dirtyResult.diff }; | ||
| } | ||
| } | ||
| // --- 4. APPLY: Read → save .bak → insert → write --- | ||
| const bakPath = `${registryPath}.bak`; | ||
| if (await fs.pathExists(bakPath)) { | ||
| return { success: false, written: [], skipped: [], errors: ['Stale .bak file exists — a previous run may have crashed. Remove it manually before retrying.'], rollbackApplied: false }; | ||
| } | ||
| try { | ||
| await fs.writeFile(bakPath, currentContent, 'utf8'); | ||
| } catch (err) { | ||
| return { success: false, written: [], skipped: [], errors: [`Failed to create backup: ${err.message}`], rollbackApplied: false }; | ||
| } | ||
| let modified; | ||
| try { | ||
| modified = applyInsertions(currentContent, moduleBlock, exportNames); | ||
| } catch (err) { | ||
| await fs.remove(bakPath); | ||
| return { success: false, written: [], skipped: [], errors: [`Insertion failed: ${err.message}`], rollbackApplied: false }; | ||
| } | ||
| try { | ||
| await fs.writeFile(registryPath, modified, 'utf8'); | ||
| } catch (err) { | ||
| // Restore from backup | ||
| await fs.writeFile(registryPath, currentContent, 'utf8'); | ||
| await fs.remove(bakPath); | ||
| return { success: false, written: [], skipped: [], errors: [`Write failed: ${err.message}`], rollbackApplied: true }; | ||
| } | ||
| // --- 5. VERIFY: Re-read + node require() --- | ||
| const verifyError = verifyRequire(registryPath); | ||
| if (verifyError) { | ||
| // Rollback | ||
| await fs.writeFile(registryPath, currentContent, 'utf8'); | ||
| await fs.remove(bakPath); | ||
| return { success: false, written: [], skipped: [], errors: [verifyError], rollbackApplied: true }; | ||
| } | ||
| // --- Cleanup: Remove .bak --- | ||
| await fs.remove(bakPath); | ||
| return { | ||
| success: true, | ||
| written: [`${prefix}_AGENTS`, `${prefix}_WORKFLOWS`, `${prefix}_AGENT_FILES`, `${prefix}_AGENT_IDS`, `${prefix}_WORKFLOW_NAMES`], | ||
| skipped: [], | ||
| errors: [], | ||
| rollbackApplied: false | ||
| }; | ||
| } | ||
| // ── Helpers ────────────────────────────────────────────────────────── | ||
| /** | ||
| * Derive SCREAMING_SNAKE_CASE prefix from team name kebab. | ||
| * @param {string} teamNameKebab - e.g. "test-team" | ||
| * @returns {string} - e.g. "TEST_TEAM" | ||
| */ | ||
| function derivePrefix(teamNameKebab) { | ||
| return (teamNameKebab || '').replace(/^_/, '').replace(/-/g, '_').toUpperCase(); | ||
| } | ||
| /** | ||
| * Build workflow names from spec data using shared deriveWorkflowName(). | ||
| * @param {Object} specData | ||
| * @returns {Object} Map of agent_id → workflow_name | ||
| */ | ||
| function buildWorkflowNames(specData) { | ||
| const map = {}; | ||
| for (const agent of (specData.agents || [])) { | ||
| map[agent.id] = deriveWorkflowName(agent, specData); | ||
| } | ||
| return map; | ||
| } | ||
| /** | ||
| * Escape single quotes in a string for JS string literal output. | ||
| * @param {string} str | ||
| * @returns {string} | ||
| */ | ||
| function escapeSingleQuotes(str) { | ||
| if (!str) return ''; | ||
| return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r'); | ||
| } | ||
| /** | ||
| * Build a registry agent entry object from enriched agent spec data. | ||
| * @param {Object} agentSpec - Agent spec with persona fields | ||
| * @param {string} teamNameKebab - Team name for stream field | ||
| * @returns {Object} | ||
| */ | ||
| function buildAgentEntry(agentSpec, teamNameKebab) { | ||
| return { | ||
| id: agentSpec.id, | ||
| name: agentSpec.name || agentSpec.id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '), | ||
| icon: agentSpec.icon || '\u{2699}', | ||
| title: agentSpec.role || agentSpec.title || agentSpec.id, | ||
| stream: teamNameKebab, | ||
| persona: { | ||
| role: agentSpec.persona?.role || agentSpec.role || '', | ||
| identity: agentSpec.persona?.identity || '', | ||
| communication_style: agentSpec.persona?.communication_style || '', | ||
| expertise: agentSpec.persona?.expertise || '', | ||
| } | ||
| }; | ||
| } | ||
| /** | ||
| * Build the module block as a JS string. | ||
| * @param {Object} specData | ||
| * @param {string} prefix - SCREAMING_SNAKE_CASE | ||
| * @param {string} teamName - Display name | ||
| * @param {Object} workflowNames - Map of agent_id → workflow_name | ||
| * @returns {string} | ||
| */ | ||
| function buildModuleBlock(specData, prefix, teamName, workflowNames) { | ||
| const agents = (specData.agents || []).map(a => buildAgentEntry(a, specData.team_name_kebab)); | ||
| const workflows = []; | ||
| for (const agent of (specData.agents || [])) { | ||
| const wfName = workflowNames[agent.id]; | ||
| if (wfName) { | ||
| workflows.push({ name: wfName, agent: agent.id }); | ||
| } | ||
| } | ||
| const lines = []; | ||
| // Section comment (padded to ~72 chars like Gyre) | ||
| const label = `── ${teamName} Module `; | ||
| const pad = Math.max(0, 72 - 3 - label.length); | ||
| lines.push(`// ${label}${'─'.repeat(pad)}`); | ||
| // AGENTS array | ||
| lines.push(`const ${prefix}_AGENTS = [`); | ||
| for (const agent of agents) { | ||
| lines.push(' {'); | ||
| lines.push(` id: '${escapeSingleQuotes(agent.id)}', name: '${escapeSingleQuotes(agent.name)}', icon: '${agent.icon}',`); | ||
| lines.push(` title: '${escapeSingleQuotes(agent.title)}', stream: '${escapeSingleQuotes(agent.stream)}',`); | ||
| lines.push(' persona: {'); | ||
| lines.push(` role: '${escapeSingleQuotes(agent.persona.role)}',`); | ||
| lines.push(` identity: '${escapeSingleQuotes(agent.persona.identity)}',`); | ||
| lines.push(` communication_style: '${escapeSingleQuotes(agent.persona.communication_style)}',`); | ||
| lines.push(` expertise: '${escapeSingleQuotes(agent.persona.expertise)}',`); | ||
| lines.push(' },'); | ||
| lines.push(' },'); | ||
| } | ||
| lines.push('];'); | ||
| lines.push(''); | ||
| // WORKFLOWS array | ||
| lines.push(`const ${prefix}_WORKFLOWS = [`); | ||
| for (const wf of workflows) { | ||
| lines.push(` { name: '${escapeSingleQuotes(wf.name)}', agent: '${escapeSingleQuotes(wf.agent)}' },`); | ||
| } | ||
| lines.push('];'); | ||
| lines.push(''); | ||
| // Derived lists | ||
| lines.push(`// Derived lists for ${teamName}`); | ||
| lines.push(`const ${prefix}_AGENT_FILES = ${prefix}_AGENTS.map(a => \`\${a.id}.md\`);`); | ||
| lines.push(`const ${prefix}_AGENT_IDS = ${prefix}_AGENTS.map(a => a.id);`); | ||
| lines.push(`const ${prefix}_WORKFLOW_NAMES = ${prefix}_WORKFLOWS.map(w => w.name);`); | ||
| return lines.join('\n'); | ||
| } | ||
| /** | ||
| * Build the list of export names to add to module.exports. | ||
| * @param {string} prefix | ||
| * @returns {string[]} | ||
| */ | ||
| function buildExportNames(prefix) { | ||
| return [ | ||
| `${prefix}_AGENTS`, | ||
| `${prefix}_WORKFLOWS`, | ||
| `${prefix}_AGENT_FILES`, | ||
| `${prefix}_AGENT_IDS`, | ||
| `${prefix}_WORKFLOW_NAMES`, | ||
| ]; | ||
| } | ||
| /** | ||
| * Validate staged content: prefix uniqueness, additive-only. | ||
| * @param {string} moduleBlock | ||
| * @param {string} prefix | ||
| * @param {string} currentContent | ||
| * @returns {string[]} errors | ||
| */ | ||
| function validateStaged(moduleBlock, prefix, currentContent) { | ||
| const errors = []; | ||
| // Check prefix collision | ||
| if (currentContent.includes(`const ${prefix}_AGENTS`)) { | ||
| errors.push(`Prefix collision: ${prefix}_AGENTS already exists in registry`); | ||
| } | ||
| // Check additive-only — no reassignment to existing variables | ||
| const existingConsts = [...currentContent.matchAll(/const\s+(\w+)\s*=/g)].map(m => m[1]); | ||
| const newConsts = [...moduleBlock.matchAll(/const\s+(\w+)\s*=/g)].map(m => m[1]); | ||
| for (const nc of newConsts) { | ||
| if (existingConsts.includes(nc)) { | ||
| errors.push(`Additive-only violation: ${nc} already exists`); | ||
| } | ||
| } | ||
| return errors; | ||
| } | ||
| /** | ||
| * Validate staged block syntax by writing to temp file and require()ing it. | ||
| * @param {string} moduleBlock | ||
| * @param {string} prefix | ||
| * @returns {Promise<string|null>} error message or null | ||
| */ | ||
| async function validateSyntax(moduleBlock, prefix) { | ||
| const tmpDir = await fs.mkdtemp(path.join(require('os').tmpdir(), 'bmad-tf-validate-')); | ||
| const tmpFile = path.join(tmpDir, 'validate-block.js'); | ||
| try { | ||
| // Wrap the block in a module so require() can parse it | ||
| const wrapped = `'use strict';\n${moduleBlock}\nmodule.exports = { ${buildExportNames(prefix).join(', ')} };\n`; | ||
| await fs.writeFile(tmpFile, wrapped, 'utf8'); | ||
| execSync(`node -e "require('${tmpFile.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')"`, { timeout: 5000, stdio: 'pipe' }); | ||
| return null; | ||
| } catch (err) { | ||
| return `Staged block syntax validation failed: ${err.stderr ? err.stderr.toString().trim() : err.message}`; | ||
| } finally { | ||
| await fs.remove(tmpDir); | ||
| } | ||
| } | ||
| /** | ||
| * Check if the registry file has uncommitted changes. | ||
| * @param {string} registryPath | ||
| * @returns {{ dirty: boolean, diff: string }} | ||
| */ | ||
| function checkDirtyTree(registryPath) { | ||
| try { | ||
| const cwd = path.dirname(registryPath); | ||
| const unstaged = execSync(`git diff --name-only -- "${registryPath}"`, { cwd, timeout: 5000, stdio: 'pipe' }).toString().trim(); | ||
| const staged = execSync(`git diff --cached --name-only -- "${registryPath}"`, { cwd, timeout: 5000, stdio: 'pipe' }).toString().trim(); | ||
| const allDiffs = [unstaged, staged].filter(Boolean).join('\n'); | ||
| return { dirty: allDiffs.length > 0, diff: allDiffs }; | ||
| } catch { | ||
| // If git is not available or file is not in a repo, proceed | ||
| return { dirty: false, diff: '' }; | ||
| } | ||
| } | ||
| /** | ||
| * Insert module block and export names into registry content. | ||
| * @param {string} content - Current file content | ||
| * @param {string} moduleBlock - New module block to insert | ||
| * @param {string[]} exportNames - Export names to add | ||
| * @returns {string} Modified content | ||
| */ | ||
| function applyInsertions(content, moduleBlock, exportNames) { | ||
| // Insert module block before `module.exports = {` | ||
| const exportsMarker = 'module.exports = {'; | ||
| const markerIdx = content.indexOf(exportsMarker); | ||
| if (markerIdx === -1) { | ||
| throw new Error('Cannot find module.exports = { marker in registry file'); | ||
| } | ||
| const before = content.slice(0, markerIdx); | ||
| const after = content.slice(markerIdx); | ||
| const withBlock = before + moduleBlock + '\n\n' + after; | ||
| // Insert export names before the closing `};` of module.exports | ||
| const closingIdx = withBlock.lastIndexOf('};'); | ||
| if (closingIdx === -1) { | ||
| throw new Error('Cannot find closing }; of module.exports'); | ||
| } | ||
| const beforeClosing = withBlock.slice(0, closingIdx); | ||
| const afterClosing = withBlock.slice(closingIdx); | ||
| const exportLines = exportNames.map(name => ` ${name},`).join('\n'); | ||
| const withExports = beforeClosing + exportLines + '\n' + afterClosing; | ||
| return withExports; | ||
| } | ||
| /** | ||
| * Verify the written file can be require()d by Node. | ||
| * @param {string} registryPath | ||
| * @returns {string|null} error message or null | ||
| */ | ||
| function verifyRequire(registryPath) { | ||
| try { | ||
| const absPath = path.resolve(registryPath); | ||
| execSync(`node -e "require('${absPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}')"`, { timeout: 5000, stdio: 'pipe' }); | ||
| return null; | ||
| } catch (err) { | ||
| return `Post-write require() verification failed: ${err.stderr ? err.stderr.toString().trim() : err.message}`; | ||
| } | ||
| } | ||
| // --- CLI entry point --- | ||
| if (require.main === module) { | ||
| const args = process.argv.slice(2); | ||
| const specFileIdx = args.indexOf('--spec-file'); | ||
| const registryPathIdx = args.indexOf('--registry-path'); | ||
| if (specFileIdx === -1 || !args[specFileIdx + 1]) { | ||
| console.error('Usage: node registry-writer.js --spec-file <path> [--registry-path <path>]'); | ||
| process.exit(1); | ||
| } | ||
| const specFilePath = args[specFileIdx + 1]; | ||
| const registryPath = registryPathIdx !== -1 && args[registryPathIdx + 1] | ||
| ? path.resolve(args[registryPathIdx + 1]) | ||
| : path.resolve(__dirname, '../../../../../scripts/update/lib/agent-registry.js'); | ||
| (async () => { | ||
| try { | ||
| const yaml = require('js-yaml'); | ||
| const specContent = await fs.readFile(specFilePath, 'utf8'); | ||
| const specData = yaml.load(specContent); | ||
| const result = await writeRegistryBlock(specData, registryPath); | ||
| console.log(JSON.stringify(result, null, 2)); | ||
| process.exit(result.success ? 0 : 1); | ||
| } catch (err) { | ||
| console.log(JSON.stringify({ success: false, errors: [err.message] }, null, 2)); | ||
| process.exit(1); | ||
| } | ||
| })(); | ||
| } | ||
| module.exports = { | ||
| writeRegistryBlock, | ||
| derivePrefix, | ||
| buildAgentEntry, | ||
| buildModuleBlock, | ||
| buildExportNames, | ||
| buildWorkflowNames, | ||
| applyInsertions, | ||
| checkDirtyTree, | ||
| verifyRequire, | ||
| escapeSingleQuotes, | ||
| }; |
| module,phase,name,code,sequence,workflow-file,command,required,agent,options,description,output-location,outputs, | ||
| _team-factory,solutioning,Create Team,CT,1,_bmad/bme/_team-factory/workflows/step-00-route.md,,optional,team-factory,,Create a new BMAD-compliant team through guided architectural discovery,_bmad-output/planning-artifacts,team-spec-{name}.yaml, | ||
| _team-factory,solutioning,Validate Team,VT,2,_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md,,optional,team-factory,,Run end-to-end validation on an existing team module,_bmad-output/planning-artifacts,validation-report.md, |
| { | ||
| "$schema": "http://json-schema.org/draft-07/schema#", | ||
| "title": "Team Spec — Independent Pattern", | ||
| "description": "Schema for Independent composition pattern team specs. Agents operate standalone with no handoff contracts.", | ||
| "type": "object", | ||
| "required": [ | ||
| "schema_version", | ||
| "team_name", | ||
| "team_name_kebab", | ||
| "composition_pattern", | ||
| "agents", | ||
| "integration" | ||
| ], | ||
| "additionalProperties": true, | ||
| "properties": { | ||
| "schema_version": { | ||
| "type": "string", | ||
| "pattern": "^\\d+\\.\\d+$", | ||
| "description": "Schema version (e.g., '1.0')" | ||
| }, | ||
| "team_name": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "description": "Human-readable team name" | ||
| }, | ||
| "team_name_kebab": { | ||
| "type": "string", | ||
| "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$", | ||
| "description": "Kebab-case team name for directory naming" | ||
| }, | ||
| "description": { | ||
| "type": "string", | ||
| "description": "What the team does" | ||
| }, | ||
| "composition_pattern": { | ||
| "type": "string", | ||
| "enum": ["Independent"], | ||
| "description": "Must be 'Independent' for this schema" | ||
| }, | ||
| "created": { | ||
| "type": "string", | ||
| "description": "ISO date string" | ||
| }, | ||
| "factory_version": { | ||
| "type": "string", | ||
| "description": "Factory version that created this spec" | ||
| }, | ||
| "agents": { | ||
| "type": "array", | ||
| "minItems": 1, | ||
| "items": { | ||
| "type": "object", | ||
| "required": ["id", "role"], | ||
| "properties": { | ||
| "id": { | ||
| "type": "string", | ||
| "pattern": "^[a-z]+(-[a-z]+)*$", | ||
| "description": "Agent ID in kebab-case" | ||
| }, | ||
| "name": { | ||
| "type": "string", | ||
| "description": "Display name (first name persona)" | ||
| }, | ||
| "icon": { | ||
| "type": "string", | ||
| "description": "Single emoji" | ||
| }, | ||
| "role": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "description": "What this agent does" | ||
| }, | ||
| "title": { | ||
| "type": "string", | ||
| "description": "Formal title" | ||
| }, | ||
| "capabilities": { | ||
| "type": "array", | ||
| "items": { "type": "string" } | ||
| }, | ||
| "overlap_acknowledgments": { | ||
| "type": "array", | ||
| "items": { "type": "string" } | ||
| }, | ||
| "persona": { | ||
| "type": "object", | ||
| "properties": { | ||
| "role": { "type": "string" }, | ||
| "identity": { "type": "string" }, | ||
| "communication_style": { "type": "string" }, | ||
| "expertise": { "type": "string" } | ||
| } | ||
| } | ||
| }, | ||
| "additionalProperties": false | ||
| } | ||
| }, | ||
| "integration": { | ||
| "type": "object", | ||
| "required": ["output_directory"], | ||
| "properties": { | ||
| "output_directory": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "description": "Where output artifacts are saved" | ||
| }, | ||
| "compass_routing": { | ||
| "type": "string", | ||
| "enum": ["optional", "per-agent"], | ||
| "description": "Compass routing mode (optional for Independent)" | ||
| } | ||
| }, | ||
| "additionalProperties": false | ||
| }, | ||
| "progress": { | ||
| "type": "object", | ||
| "properties": { | ||
| "orient": { "type": "string" }, | ||
| "scope": { "type": "string" }, | ||
| "connect": { "type": "string" }, | ||
| "review": { "type": "string" }, | ||
| "generate": {}, | ||
| "validate": { "type": "string" } | ||
| } | ||
| }, | ||
| "decisions": { | ||
| "type": "array", | ||
| "items": { | ||
| "type": "object", | ||
| "properties": { | ||
| "step": { "type": "string" }, | ||
| "decision": { "type": "string" }, | ||
| "default_accepted": { "type": "boolean" }, | ||
| "rationale": { "type": "string" } | ||
| } | ||
| } | ||
| }, | ||
| "metrics": { | ||
| "type": "object", | ||
| "properties": { | ||
| "discovery_path": {}, | ||
| "hardest_step": {}, | ||
| "would_use_again": {} | ||
| } | ||
| } | ||
| } | ||
| } |
| { | ||
| "$schema": "http://json-schema.org/draft-07/schema#", | ||
| "title": "Team Spec — Sequential Pattern", | ||
| "description": "Schema for Sequential composition pattern team specs. Agents form a pipeline with handoff contracts.", | ||
| "type": "object", | ||
| "required": [ | ||
| "schema_version", | ||
| "team_name", | ||
| "team_name_kebab", | ||
| "composition_pattern", | ||
| "agents", | ||
| "contracts", | ||
| "integration" | ||
| ], | ||
| "additionalProperties": true, | ||
| "properties": { | ||
| "schema_version": { | ||
| "type": "string", | ||
| "pattern": "^\\d+\\.\\d+$", | ||
| "description": "Schema version (e.g., '1.0')" | ||
| }, | ||
| "team_name": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "description": "Human-readable team name" | ||
| }, | ||
| "team_name_kebab": { | ||
| "type": "string", | ||
| "pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$", | ||
| "description": "Kebab-case team name for directory naming" | ||
| }, | ||
| "description": { | ||
| "type": "string", | ||
| "description": "What the team does" | ||
| }, | ||
| "composition_pattern": { | ||
| "type": "string", | ||
| "enum": ["Sequential"], | ||
| "description": "Must be 'Sequential' for this schema" | ||
| }, | ||
| "created": { | ||
| "type": "string", | ||
| "description": "ISO date string" | ||
| }, | ||
| "factory_version": { | ||
| "type": "string", | ||
| "description": "Factory version that created this spec" | ||
| }, | ||
| "agents": { | ||
| "type": "array", | ||
| "minItems": 1, | ||
| "items": { | ||
| "type": "object", | ||
| "required": ["id", "role", "pipeline_position"], | ||
| "properties": { | ||
| "id": { | ||
| "type": "string", | ||
| "pattern": "^[a-z]+(-[a-z]+)*$", | ||
| "description": "Agent ID in kebab-case" | ||
| }, | ||
| "name": { | ||
| "type": "string", | ||
| "description": "Display name (first name persona)" | ||
| }, | ||
| "icon": { | ||
| "type": "string", | ||
| "description": "Single emoji" | ||
| }, | ||
| "role": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "description": "What this agent does" | ||
| }, | ||
| "title": { | ||
| "type": "string", | ||
| "description": "Formal title" | ||
| }, | ||
| "capabilities": { | ||
| "type": "array", | ||
| "items": { "type": "string" } | ||
| }, | ||
| "pipeline_position": { | ||
| "type": "integer", | ||
| "minimum": 1, | ||
| "description": "Position in the pipeline (1-based)" | ||
| }, | ||
| "overlap_acknowledgments": { | ||
| "type": "array", | ||
| "items": { "type": "string" } | ||
| }, | ||
| "persona": { | ||
| "type": "object", | ||
| "properties": { | ||
| "role": { "type": "string" }, | ||
| "identity": { "type": "string" }, | ||
| "communication_style": { "type": "string" }, | ||
| "expertise": { "type": "string" } | ||
| } | ||
| } | ||
| }, | ||
| "additionalProperties": false | ||
| } | ||
| }, | ||
| "contracts": { | ||
| "type": "array", | ||
| "minItems": 1, | ||
| "description": "Handoff contracts between pipeline agents", | ||
| "items": { | ||
| "type": "object", | ||
| "required": ["id", "source_agent", "target_agents"], | ||
| "properties": { | ||
| "id": { | ||
| "type": "string", | ||
| "description": "Contract ID (e.g., HC1, GC1)" | ||
| }, | ||
| "source_agent": { | ||
| "type": "string", | ||
| "description": "Agent ID that produces the artifact" | ||
| }, | ||
| "target_agents": { | ||
| "type": "array", | ||
| "minItems": 1, | ||
| "items": { "type": "string" }, | ||
| "description": "Agent IDs that consume the artifact" | ||
| }, | ||
| "artifact_title": { | ||
| "type": "string", | ||
| "description": "Human-readable artifact name" | ||
| }, | ||
| "artifact_description": { | ||
| "type": "string", | ||
| "description": "What the artifact contains" | ||
| }, | ||
| "key_sections": { | ||
| "type": "array", | ||
| "items": { "type": "string" }, | ||
| "description": "Main sections of the artifact" | ||
| }, | ||
| "file_name": { | ||
| "type": "string", | ||
| "description": "Output file name for the artifact" | ||
| }, | ||
| "input_artifacts": { | ||
| "type": "array", | ||
| "items": { "type": "string" }, | ||
| "description": "Contract IDs this artifact depends on" | ||
| }, | ||
| "optional": { | ||
| "type": "boolean", | ||
| "default": false, | ||
| "description": "Whether this contract is optional" | ||
| } | ||
| }, | ||
| "additionalProperties": false | ||
| } | ||
| }, | ||
| "feedback_contracts": { | ||
| "type": "array", | ||
| "description": "Optional feedback routing contracts (downstream → upstream)", | ||
| "items": { | ||
| "type": "object", | ||
| "required": ["id", "source_agent", "target_agents"], | ||
| "properties": { | ||
| "id": { "type": "string" }, | ||
| "source_agent": { "type": "string" }, | ||
| "target_agents": { | ||
| "type": "array", | ||
| "minItems": 1, | ||
| "items": { "type": "string" } | ||
| }, | ||
| "artifact_title": { "type": "string" }, | ||
| "artifact_description": { "type": "string" }, | ||
| "key_sections": { | ||
| "type": "array", | ||
| "items": { "type": "string" } | ||
| }, | ||
| "file_name": { "type": "string" }, | ||
| "input_artifacts": { | ||
| "type": "array", | ||
| "items": { "type": "string" } | ||
| }, | ||
| "optional": { "type": "boolean", "default": true } | ||
| }, | ||
| "additionalProperties": false | ||
| } | ||
| }, | ||
| "integration": { | ||
| "type": "object", | ||
| "required": ["output_directory", "contract_prefix"], | ||
| "properties": { | ||
| "output_directory": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "description": "Where output artifacts are saved" | ||
| }, | ||
| "compass_routing": { | ||
| "type": "string", | ||
| "enum": ["required", "shared-reference"], | ||
| "description": "Compass routing mode (required for Sequential)" | ||
| }, | ||
| "contract_prefix": { | ||
| "type": "string", | ||
| "minLength": 1, | ||
| "pattern": "^[A-Z]+$", | ||
| "description": "Prefix for contract IDs (e.g., HC, GC, FG)" | ||
| } | ||
| }, | ||
| "additionalProperties": false | ||
| }, | ||
| "progress": { | ||
| "type": "object", | ||
| "properties": { | ||
| "orient": { "type": "string" }, | ||
| "scope": { "type": "string" }, | ||
| "connect": { "type": "string" }, | ||
| "review": { "type": "string" }, | ||
| "generate": {}, | ||
| "validate": { "type": "string" } | ||
| } | ||
| }, | ||
| "decisions": { | ||
| "type": "array", | ||
| "items": { | ||
| "type": "object", | ||
| "properties": { | ||
| "step": { "type": "string" }, | ||
| "decision": { "type": "string" }, | ||
| "default_accepted": { "type": "boolean" }, | ||
| "rationale": { "type": "string" } | ||
| } | ||
| } | ||
| }, | ||
| "metrics": { | ||
| "type": "object", | ||
| "properties": { | ||
| "discovery_path": {}, | ||
| "hardest_step": {}, | ||
| "would_use_again": {} | ||
| } | ||
| } | ||
| } | ||
| } |
| # Team Spec Template — Express Mode Input | ||
| # Fill in this template and pass to the factory for rapid team creation. | ||
| # Fields marked REQUIRED must be provided. Optional fields can be removed. | ||
| # | ||
| # Usage: Save as team-spec-{your-team-name}.yaml in _bmad-output/planning-artifacts/ | ||
| # Then invoke the factory in Express Mode with this file as input. | ||
| schema_version: "1.0" | ||
| # REQUIRED: Human-readable team name | ||
| team_name: "" | ||
| # REQUIRED: Kebab-case team name (used for directory naming) | ||
| # Must match: /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/ | ||
| team_name_kebab: "" | ||
| # Optional: What does this team do? | ||
| description: "" | ||
| # REQUIRED: "Independent" or "Sequential" | ||
| # Independent: Agents operate standalone, no contracts between them | ||
| # Sequential: Agents form a pipeline with handoff contracts | ||
| composition_pattern: "" | ||
| # Auto-populated by factory | ||
| created: "" | ||
| factory_version: "1.0.0" | ||
| # REQUIRED: At least one agent | ||
| # Agent IDs must match: /^[a-z]+(-[a-z]+)*$/ | ||
| agents: | ||
| - id: "" | ||
| name: "" # Display name (first name persona) | ||
| icon: "" # Single emoji | ||
| role: "" # REQUIRED: What this agent does | ||
| title: "" # Formal title | ||
| capabilities: | ||
| - "" | ||
| # Sequential only — remove for Independent | ||
| # pipeline_position: 1 | ||
| overlap_acknowledgments: [] | ||
| # Sequential only — remove entire section for Independent | ||
| # At least one contract required for Sequential teams | ||
| # contracts: | ||
| # - id: "HC1" | ||
| # source_agent: "" | ||
| # target_agents: | ||
| # - "" | ||
| # artifact_title: "" | ||
| # artifact_description: "" | ||
| # key_sections: | ||
| # - "" | ||
| # file_name: "" | ||
| # input_artifacts: [] | ||
| # optional: false | ||
| # Sequential only — optional feedback routing contracts | ||
| # feedback_contracts: [] | ||
| # REQUIRED: Integration settings | ||
| integration: | ||
| output_directory: "_bmad-output/{team-name}-artifacts" | ||
| # "optional" or "per-agent" for Independent | ||
| # "required" or "shared-reference" for Sequential | ||
| compass_routing: "" | ||
| # Sequential only — prefix for contract IDs (e.g., "HC", "GC") | ||
| # contract_prefix: "" | ||
| # Auto-managed by factory — do not edit manually | ||
| progress: | ||
| orient: "pending" | ||
| scope: "pending" | ||
| connect: "pending" | ||
| review: "pending" | ||
| generate: "pending" | ||
| validate: "pending" | ||
| # Auto-populated by factory during guided mode | ||
| decisions: [] | ||
| # Post-completion metrics (factory populates after run) | ||
| metrics: | ||
| discovery_path: null | ||
| hardest_step: null | ||
| would_use_again: null |
| # Step 01: Scope — Team Definition & Agent Inventory | ||
| ## Purpose | ||
| Guide the contributor through defining their team's composition pattern, agents, and their roles. This is where architectural thinking happens — before any files are generated. | ||
| ## Prerequisites | ||
| - Step 00 (Route) completed — contributor confirmed "Create Team" | ||
| ## Execution Sequence | ||
| ### 1. Team Identity | ||
| Ask the contributor for: | ||
| - **Team name** (human-readable, e.g., "Loom", "Gyre", "Sentinel") | ||
| - **What does this team do?** (1-2 sentence description) | ||
| - **Who uses it?** (target user persona) | ||
| Derive `team_name_kebab` from the team name using naming conventions: | ||
| - Lowercase, hyphens between words | ||
| - Must match: `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/` | ||
| - Confirm with contributor: "Your team module will be at `_bmad/bme/_{team_name_kebab}/`" | ||
| ### 2. Composition Pattern Selection | ||
| Present the two patterns with plain-language descriptions and examples: | ||
| > **Independent Pattern** | ||
| > Agents operate standalone — each handles a separate concern. No handoff contracts between them. | ||
| > _Think: a toolbox where each tool works on its own._ | ||
| > Example: A team with a "documentation auditor" and a "dependency checker" — related but independent tasks. | ||
| > | ||
| > **Sequential Pattern** | ||
| > Agents form a pipeline — each one's output feeds the next. Handoff contracts define what gets passed. | ||
| > _Think: an assembly line where each station adds something._ | ||
| > Examples: Vortex (7-agent discovery pipeline), Gyre (4-agent readiness analysis) | ||
| **Default suggestion:** Based on the team description, suggest the most likely pattern with reasoning. | ||
| **Cascade effect:** Once selected, the factory eliminates irrelevant decisions: | ||
| - **Independent selected** → Skip: contract design, pipeline ordering, feedback contracts, contract prefix, compass routing (mark as optional) | ||
| - **Sequential selected** → All decisions remain active | ||
| Load cascade logic: | ||
| ``` | ||
| run: node -e "const c = require('{project-root}/_bmad/bme/_team-factory/lib/cascade-logic.js'); console.log(JSON.stringify(c.getCascadeForPattern('{pattern}'), null, 2))" | ||
| expect: result.decisions → active decisions for this pattern | ||
| result.eliminated → decisions removed by pattern selection | ||
| ``` | ||
| ### 3. Agent Inventory | ||
| For each agent, collect: | ||
| - **Agent ID** (kebab-case, e.g., `knowledge-surveyor`) — enforce: `/^[a-z]+(-[a-z]+)*$/` | ||
| - **Display name** (first name persona, e.g., "Silo") | ||
| - **Icon** (single emoji) | ||
| - **Role** (what does this agent do — 1 sentence) | ||
| - **Title** (formal title, e.g., "Knowledge Survey Specialist") | ||
| - **Capabilities** (list of 2-5 key capabilities) | ||
| - **Pipeline position** (Sequential only — integer, 1-based) | ||
| Present agents one at a time. After each agent, ask: "Add another agent, or are you done?" | ||
| **Naming enforcement per agent:** | ||
| ``` | ||
| run: node -e "const n = require('{project-root}/_bmad/bme/_team-factory/lib/utils/naming-utils.js'); const id = '{agent_id}'; console.log(JSON.stringify({ valid: /^[a-z]+(-[a-z]+)*$/.test(id), id }))" | ||
| expect: result.valid === true → proceed | ||
| result.valid === false → "Agent ID must be lowercase letters and hyphens only (e.g., 'knowledge-surveyor'). Got: '{agent_id}'" | ||
| ``` | ||
| ### 4. Overlap Detection | ||
| After all agents are defined, run collision detection: | ||
| ``` | ||
| run: node -e "const cd = require('{project-root}/_bmad/bme/_team-factory/lib/collision-detector.js'); cd.detectCollisions({team_name_kebab: '{kebab}', agents: [{id: '{id1}'}, ...]}, '{project-root}/_bmad/_config/agent-manifest.csv', '{project-root}/_bmad/bme/').then(r => console.log(JSON.stringify(r, null, 2)))" | ||
| expect: result.hasBlocking === false → proceed with optional warnings | ||
| result.hasBlocking === true → display blocks, ask contributor to rename | ||
| result.warnings.length > 0 → display warnings, ask contributor to acknowledge or rename | ||
| ``` | ||
| **Level 3 (LLM capability overlap):** After JS detection, review each agent's role and capabilities against the existing agent manifest. Flag potential functional overlaps: | ||
| - "Your agent `{id}` with role '{role}' may overlap with existing agent `{existing_id}` ({existing_role}) in the {existing_module} module." | ||
| - Ask: "Is this intentional? If so, I'll note the acknowledgment." | ||
| ### 5. Save Progress | ||
| Initialize the spec file: | ||
| - Create `team-spec-{team_name_kebab}.yaml` in `_bmad-output/planning-artifacts/` | ||
| - Populate: team identity, composition pattern, agents, overlap acknowledgments | ||
| - Set progress: `orient: complete, scope: complete, connect: pending, ...` | ||
| Display: "Scope complete. {N} agents defined for team **{team_name}** ({pattern} pattern). Moving to integration design." | ||
| Proceed to: `{project-root}/_bmad/bme/_team-factory/workflows/add-team/step-02-connect.md` | ||
| ## Visibility Checklist — Step 1 | ||
| Colleague sees: | ||
| - [ ] Team identity questions (name, description, users) | ||
| - [ ] Pattern selection with examples and recommendation | ||
| - [ ] Agent definition prompts (one at a time) | ||
| Runs silently: | ||
| - [ ] Naming validation (per agent) | ||
| - [ ] Cascade logic (pattern → eliminated decisions) | ||
| - [ ] Collision detection (JS Level 1-2) | ||
| Concept count: 3/3 (team identity, pattern, agents) | ||
| Approval prompt: "Here's your team scope: {summary}. Ready to design integration?" |
| # Step 02: Connect — Integration Design | ||
| ## Purpose | ||
| Guide the contributor through integration decisions: contracts (Sequential only), output directory, compass routing, and config field design. This step produces the integration section of the spec file. | ||
| ## Prerequisites | ||
| - Step 01 (Scope) completed — team identity, pattern, and agents defined | ||
| - Spec file exists with progress.scope = "complete" | ||
| ## Execution Sequence | ||
| ### 1. Load Context | ||
| Read the spec file to get the team's composition pattern and agents. | ||
| ### 2. Contract Design (Sequential Only) | ||
| > Skip this section entirely for Independent teams — cascade logic eliminated it. | ||
| For Sequential teams, guide through handoff contract design: | ||
| **Contract prefix:** | ||
| - Ask: "What prefix should your contracts use? Examples: HC (Vortex), GC (Gyre). This prefix + number becomes the contract ID (e.g., HC1, HC2)." | ||
| - Default suggestion based on team name initials | ||
| **Forward contracts (required):** | ||
| For each adjacent pair of agents in pipeline order: | ||
| - "Agent **{source}** (position {N}) passes output to **{target}** (position {N+1})" | ||
| - Ask for each contract: | ||
| - **Artifact title** — What is this artifact called? | ||
| - **Artifact description** — What does it contain? (1-2 sentences) | ||
| - **Key sections** — What are the main sections of this artifact? | ||
| - **File naming** — Default: `{prefix}{N}-{artifact-kebab}.md` | ||
| Present each contract for confirmation before moving to the next. | ||
| **Feedback contracts (optional):** | ||
| - "Does any downstream agent need to send structured feedback back upstream?" | ||
| - If yes: guide through the same contract template | ||
| - If no: skip — "No feedback contracts. You can add them later." | ||
| **Contract validation (B-lite semantic check):** | ||
| For each contract, verify: | ||
| - Source agent exists in the agent list | ||
| - Target agent(s) exist in the agent list | ||
| - No duplicate contract IDs | ||
| ### 3. Output Directory | ||
| - Default: `_bmad-output/{team_name_kebab}-artifacts` | ||
| - Ask: "Where should your team's output artifacts be saved?" | ||
| - Validate: path should start with `_bmad-output/` | ||
| ### 4. Compass Routing | ||
| **Sequential teams:** | ||
| - Required. "Your team needs a compass routing reference — a navigation table that helps users know what to do next after each workflow." | ||
| - Default: `shared-reference` (single file, like Vortex's `compass-routing-reference.md`) | ||
| **Independent teams:** | ||
| - Optional. "Would you like per-agent compass routing? This is optional for Independent teams since agents operate standalone." | ||
| - Default: `per-agent` or `optional` | ||
| ### 5. Config Field Review | ||
| Summarize what will go into the team's `config.yaml`: | ||
| ``` | ||
| submodule_name: _{team_name_kebab} | ||
| description: {description} | ||
| module: bme | ||
| output_folder: '{project-root}/{output_directory}' | ||
| agents: [{agent_ids}] | ||
| workflows: [{workflow_names}] | ||
| version: 1.0.0 | ||
| user_name: '{user}' | ||
| communication_language: en | ||
| party_mode_enabled: true | ||
| core_module: bme | ||
| ``` | ||
| Run config field collision detection: | ||
| ``` | ||
| run: node -e "const cc = require('{project-root}/_bmad/bme/_team-factory/lib/writers/config-creator.js'); // collision check logic" | ||
| expect: no collisions with existing config fields | ||
| ``` | ||
| ### 6. Save Progress | ||
| Update the spec file: | ||
| - Add contracts, feedback_contracts, integration section | ||
| - Set progress: `connect: complete` | ||
| Display summary: | ||
| - "{N} forward contracts, {M} feedback contracts defined" (Sequential) | ||
| - "Output: {output_directory}, Compass routing: {routing_mode}" | ||
| - "Moving to decision review." | ||
| Proceed to: `{project-root}/_bmad/bme/_team-factory/workflows/add-team/step-03-review.md` | ||
| ## Visibility Checklist — Step 2 | ||
| Colleague sees: | ||
| - [ ] Contract design prompts (Sequential only, one at a time) | ||
| - [ ] Output directory confirmation | ||
| - [ ] Config field summary | ||
| Runs silently: | ||
| - [ ] Contract validation (source/target exist, no duplicate IDs) | ||
| - [ ] Config field collision detection | ||
| - [ ] Cascade elimination check (contracts skipped for Independent) | ||
| Concept count: 2/3 (contracts, integration settings) — or 1/3 for Independent (integration only) | ||
| Approval prompt: "Integration design complete. Ready for the full decision review?" |
| # Step 03: Review — Decision Summary & Approval | ||
| ## Purpose | ||
| Present all decisions for contributor approval before generation begins. In Express Mode, this step is the full validation gate — all per-step and semantic checks run in batch. | ||
| ## Prerequisites | ||
| - Step 02 (Connect) completed — all decisions made | ||
| - Spec file has progress.connect = "complete" | ||
| ## Execution Sequence | ||
| ### 1. Load Spec | ||
| Read the complete spec file. This is the ground truth for all decisions. | ||
| ### 2. Decision Summary | ||
| Present a structured summary of all decisions made: | ||
| ``` | ||
| ═══════════════════════════════════════════════════ | ||
| TEAM FACTORY — Decision Summary | ||
| ═══════════════════════════════════════════════════ | ||
| Team: {team_name} (_{team_name_kebab}) | ||
| Pattern: {composition_pattern} | ||
| Module: _bmad/bme/_{team_name_kebab}/ | ||
| AGENTS ({count}): | ||
| ┌─────┬──────────────────────┬──────┬─────────────────────────┐ | ||
| │ Pos │ ID │ Icon │ Role │ | ||
| ├─────┼──────────────────────┼──────┼─────────────────────────┤ | ||
| │ 1 │ {agent_id} │ {ic} │ {role} │ | ||
| └─────┴──────────────────────┴──────┴─────────────────────────┘ | ||
| CONTRACTS ({count}): [Sequential only] | ||
| {prefix}{N}: {source} → {target} — "{artifact_title}" | ||
| FEEDBACK CONTRACTS ({count}): [Sequential only] | ||
| {prefix}{N}: {source} → {target} — "{artifact_title}" | ||
| INTEGRATION: | ||
| Output: {output_directory} | ||
| Routing: {compass_routing} | ||
| Prefix: {contract_prefix} [Sequential only] | ||
| OVERLAP ACKNOWLEDGMENTS ({count}): | ||
| {agent_id} ↔ {existing_id}: acknowledged | ||
| ═══════════════════════════════════════════════════ | ||
| ``` | ||
| ### 3. Validation Gate (All Modes) | ||
| Run all per-step validations in batch against the spec file: | ||
| **Naming validation:** | ||
| - team_name_kebab matches `/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/` | ||
| - All agent IDs match `/^[a-z]+(-[a-z]+)*$/` | ||
| **Pattern validation:** | ||
| - Composition pattern is "Independent" or "Sequential" | ||
| - Sequential teams have contracts and contract_prefix | ||
| - Independent teams have no contracts | ||
| **Semantic validation (B-lite):** | ||
| - All contract source_agents exist in agents list | ||
| - All contract target_agents exist in agents list | ||
| - No duplicate contract IDs | ||
| - Pipeline positions are sequential (Sequential only) | ||
| **Collision re-check:** | ||
| - Run collision detector again (working tree may have changed since Step 01) | ||
| Display validation results: | ||
| - "✅ All {N} checks passed" or | ||
| - "❌ {N} issues found:" followed by each issue with explanation | ||
| ### 4. Contributor Approval | ||
| Ask explicitly: | ||
| > "All decisions above will be used to generate your team. Would you like to: | ||
| > 1. **Approve** — proceed to generation | ||
| > 2. **Edit** — go back and change specific decisions | ||
| > 3. **Save & Exit** — save the spec file and resume later" | ||
| **If Edit:** Ask which section to change, update the spec file, re-display summary. | ||
| **If Save & Exit:** Ensure progress.review = "pending" (so re-entry returns here), display spec file path. | ||
| **If Approve:** Set progress.review = "complete", proceed. | ||
| ### 5. Express Mode Behavior | ||
| In Express Mode (spec file loaded directly): | ||
| - Skip to this step with all decisions pre-loaded | ||
| - Run the full validation gate | ||
| - If all checks pass: display summary, ask for approval | ||
| - If checks fail: display errors, ask contributor to fix the spec file and re-run | ||
| ### 6. Save Progress | ||
| Update spec file: progress.review = "complete" | ||
| Proceed to: `{project-root}/_bmad/bme/_team-factory/workflows/add-team/step-04-generate.md` | ||
| ## Visibility Checklist — Step 3 | ||
| Colleague sees: | ||
| - [ ] Complete decision summary table | ||
| - [ ] Validation results (pass/fail) | ||
| - [ ] Approve/Edit/Save choice | ||
| Runs silently: | ||
| - [ ] Naming validation batch | ||
| - [ ] Pattern validation | ||
| - [ ] Semantic validation (B-lite) | ||
| - [ ] Collision re-check | ||
| Concept count: 1/3 (decision review — everything else is summary of prior decisions) | ||
| Approval prompt: "Approve these decisions and proceed to file generation?" |
| # Step 04: Generate — File Creation & Integration Wiring | ||
| ## Purpose | ||
| Generate all team files through BMB delegation for content artifacts and factory JS utilities for integration wiring. This is the step where decisions become files. | ||
| ## Prerequisites | ||
| - Step 03 (Review) completed — contributor approved all decisions | ||
| - Spec file has progress.review = "complete" | ||
| ## Execution Sequence | ||
| ### 1. Load Spec & Plan Generation | ||
| Read the spec file. Build the generation plan: | ||
| **Files to generate (per agent):** | ||
| 1. Agent definition file: `_bmad/bme/_{team}/agents/{agent_id}.md` | ||
| 2. Workflow files: `_bmad/bme/_{team}/workflows/{workflow_name}/workflow.md` + step files | ||
| 3. Contract files (Sequential): `_bmad/bme/_{team}/contracts/{contract_id}.md` | ||
| 4. User guide: `_bmad/bme/_{team}/guides/{NAME}-USER-GUIDE.md` | ||
| **Integration files (whole team):** | ||
| 5. Config: `_bmad/bme/_{team}/config.yaml` | ||
| 6. Module help CSV: `_bmad/bme/_{team}/module-help.csv` | ||
| 7. README: `_bmad/bme/_{team}/README.md` | ||
| 8. Compass routing (Sequential): `_bmad/bme/_{team}/compass-routing-reference.md` | ||
| **Shared file modifications:** | ||
| 9. Registry block in `scripts/update/lib/agent-registry.js` | ||
| Display the plan: "{N} files to create, 1 shared file to modify." | ||
| ### 2. Directory Structure | ||
| Create the module directory tree: | ||
| ``` | ||
| _bmad/bme/_{team_name_kebab}/ | ||
| agents/ | ||
| workflows/ | ||
| contracts/ (Sequential only) | ||
| guides/ | ||
| config.yaml | ||
| module-help.csv | ||
| README.md | ||
| ``` | ||
| ### 3. Agent Generation (Sequential, Per-Agent) | ||
| For each agent in pipeline order: | ||
| **3a. Agent Definition (BMB Delegation)** | ||
| Delegate to BMB (Bond) to generate the agent `.md` file. Provide full context: | ||
| - Agent ID, name, icon, role, title, capabilities | ||
| - Team name and composition pattern | ||
| - Config path: `_bmad/bme/_{team}/config.yaml` | ||
| - Communication style guidance from spec | ||
| The agent file must follow the standard BMAD agent template: | ||
| - Frontmatter with name and description | ||
| - Activation XML with config loading, menu, handlers, rules | ||
| - Persona section (role, identity, communication_style, principles) | ||
| - Menu items pointing to the agent's workflows | ||
| **3b. Workflow Generation (BMB Delegation)** | ||
| For each agent's workflows, delegate to BMB to generate: | ||
| - `workflow.md` — entry point with step sequence | ||
| - Step files — one per workflow step | ||
| **3c. Per-Agent Validation** | ||
| After generating each agent's files: | ||
| ``` | ||
| run: node -e "const av = require('{project-root}/_bmad/bme/_team-factory/lib/writers/activation-validator.js'); av.validateActivation('{agent_file_path}', '{config_path}').then(r => console.log(JSON.stringify(r)))" | ||
| expect: result.valid === true → proceed to next agent | ||
| result.valid === false → display errors, fix before continuing | ||
| ``` | ||
| **3d. Update Progress** | ||
| Update spec file: `progress.generate.{agent_id}: "complete"` | ||
| ### 4. Contract Generation (Sequential Only) | ||
| For each contract in the spec: | ||
| - Generate contract file at `_bmad/bme/_{team}/contracts/{contract_id}.md` | ||
| - Include: frontmatter (contract ID, source, targets, type), artifact schema, key sections | ||
| - Follow existing contract patterns (Vortex HC1-HC10, Gyre GC1-GC4) | ||
| ### 5. Integration Wiring (Factory-Owned) | ||
| **5a. Config Creation** | ||
| ``` | ||
| run: node -e "const cc = require('{project-root}/_bmad/bme/_team-factory/lib/writers/config-creator.js'); cc.createConfig({spec_data}).then(r => console.log(JSON.stringify(r)))" | ||
| expect: result.success === true | ||
| ``` | ||
| **5b. CSV Creation** | ||
| ``` | ||
| run: node -e "const csv = require('{project-root}/_bmad/bme/_team-factory/lib/writers/csv-creator.js'); csv.createCsv({spec_data}).then(r => console.log(JSON.stringify(r)))" | ||
| expect: result.success === true | ||
| ``` | ||
| **5c. Registry Block (Full Write Safety Protocol)** | ||
| ``` | ||
| run: node -e "const rw = require('{project-root}/_bmad/bme/_team-factory/lib/writers/registry-writer.js'); rw.writeRegistryBlock({spec_data}, '{registry_path}').then(r => console.log(JSON.stringify(r)))" | ||
| expect: result.success === true → proceed | ||
| result.dirty === true → warn contributor, ask for confirmation | ||
| result.success === false → display errors, attempt rollback | ||
| ``` | ||
| **IMPORTANT:** The registry write uses the Full Write Safety Protocol: | ||
| 1. **Stage** — Build module block in memory | ||
| 2. **Validate** — Syntax check, prefix uniqueness, additive-only | ||
| 3. **Check** — Dirty-tree detection (git diff on agent-registry.js) | ||
| 4. **Apply** — Backup → write → verify | ||
| 5. **Verify** — Re-read + `node require()` post-write validation | ||
| Show contributor: "Here's what will be added to agent-registry.js: [preview block]. Approve?" | ||
| ### 6. Compass Routing (Sequential Only) | ||
| Generate `compass-routing-reference.md` with: | ||
| - Navigation table mapping workflows to agents | ||
| - "What do I do next?" routing for each workflow completion | ||
| - Cross-team routing suggestions if applicable | ||
| ### 7. README Generation | ||
| Generate `README.md` with: | ||
| - Team description and purpose | ||
| - Agent roster with roles | ||
| - Quick start instructions | ||
| - Workflow overview | ||
| ### 8. Manifest Tracking | ||
| Track all created and modified files: | ||
| ``` | ||
| run: node -e "const mt = require('{project-root}/_bmad/bme/_team-factory/lib/manifest-tracker.js'); mt.buildManifest({spec_data}).then(r => console.log(JSON.stringify(r)))" | ||
| ``` | ||
| ### 9. Save Progress | ||
| Update spec file: `progress.generate: "complete"` (all agents done) | ||
| Display: "Generation complete. {N} files created, 1 file modified. Moving to validation." | ||
| Proceed to: `{project-root}/_bmad/bme/_team-factory/workflows/add-team/step-05-validate.md` | ||
| ## Visibility Checklist — Step 4 | ||
| Colleague sees: | ||
| - [ ] Generation plan (file count and list) | ||
| - [ ] Per-agent progress ("Generating agent {name}...") | ||
| - [ ] Registry block preview + approval prompt | ||
| Runs silently: | ||
| - [ ] BMB delegation calls | ||
| - [ ] Activation validation per agent | ||
| - [ ] Config/CSV creation | ||
| - [ ] Write Safety Protocol (registry) | ||
| - [ ] Manifest tracking | ||
| Concept count: 2/3 (generation progress, registry approval) | ||
| Approval prompt: "Here's what will be added to agent-registry.js — approve?" |
| # Step 05: Validate — End-to-End Verification & Completion | ||
| ## Purpose | ||
| Run comprehensive validation on the generated team, produce a file manifest, collect metrics, and conclude the factory run. | ||
| ## Prerequisites | ||
| - Step 04 (Generate) completed — all files created and wired | ||
| - Spec file has progress.generate = "complete" (or all agent sub-entries complete) | ||
| ## Execution Sequence | ||
| ### 1. Load Spec & Manifest | ||
| Read the spec file and the generation manifest (list of all created/modified files). | ||
| ### 2. End-to-End Validation | ||
| Run the full validation suite: | ||
| ``` | ||
| run: node -e "const v = require('{project-root}/_bmad/bme/_team-factory/lib/validators/end-to-end-validator.js'); v.validateTeam({spec_data}, '{project-root}').then(r => console.log(JSON.stringify(r, null, 2)))" | ||
| expect: result.valid === true → all checks passed | ||
| result.valid === false → display failing checks with details | ||
| ``` | ||
| The end-to-end validator checks: | ||
| **Structural checks:** | ||
| - Config.yaml exists and parses correctly | ||
| - Every agent file declared in config exists | ||
| - Every workflow has a workflow.md entry point | ||
| - Contract files exist (Sequential only) | ||
| - README exists | ||
| **Wiring checks:** | ||
| - Agent registry block exists with correct prefix | ||
| - All agents appear in the registry AGENTS array | ||
| - All workflows appear in the registry WORKFLOWS array | ||
| - Derived lists (AGENT_FILES, AGENT_IDS, WORKFLOW_NAMES) are correct | ||
| - Registry file passes `node require()` verification | ||
| **Naming checks:** | ||
| - Module directory matches `_{team_name_kebab}` | ||
| - Agent file names match agent IDs | ||
| - All names conform to naming conventions | ||
| **Pattern-specific checks (Sequential only):** | ||
| - At least one handoff contract exists | ||
| - Contract source/target agents match agent list | ||
| - Compass routing reference exists | ||
| ### 3. Regression Check | ||
| Verify that existing teams still pass validation: | ||
| ``` | ||
| run: node -e "require('{project-root}/scripts/update/lib/validator.js')" logic | ||
| ``` | ||
| This confirms the factory's changes to shared files (agent-registry.js) didn't break existing teams. | ||
| ### 4. Display Results | ||
| **If all checks pass:** | ||
| ``` | ||
| ═══════════════════════════════════════════════════ | ||
| ✅ TEAM FACTORY — Validation Complete | ||
| ═══════════════════════════════════════════════════ | ||
| Team: {team_name} (_{team_name_kebab}) | ||
| Pattern: {composition_pattern} | ||
| Agents: {count} | ||
| Checks: {passed}/{total} passed | ||
| FILES CREATED: | ||
| ├── _bmad/bme/_{team}/config.yaml | ||
| ├── _bmad/bme/_{team}/module-help.csv | ||
| ├── _bmad/bme/_{team}/README.md | ||
| ├── _bmad/bme/_{team}/agents/{agent_id}.md | ||
| ├── _bmad/bme/_{team}/workflows/... | ||
| ├── _bmad/bme/_{team}/contracts/... [Sequential] | ||
| └── _bmad/bme/_{team}/guides/... | ||
| FILES MODIFIED: | ||
| └── scripts/update/lib/agent-registry.js | ||
| Spec file: _bmad-output/planning-artifacts/team-spec-{team}.yaml | ||
| ═══════════════════════════════════════════════════ | ||
| ``` | ||
| **If checks fail:** | ||
| Display each failing check with: | ||
| - Check name (e.g., CONFIG-EXISTS, REGISTRY-BLOCK) | ||
| - Step name (structural, wiring, naming, pattern) | ||
| - Expected vs actual values (per TF-NFR11) | ||
| - Suggested fix | ||
| Ask: "Would you like to fix these issues and re-validate, or save current state and resume later?" | ||
| ### 5. Post-Completion Metrics | ||
| If validation passed, collect two brief metrics: | ||
| 1. "What was the hardest step?" (orient/scope/connect/review/generate/validate) | ||
| 2. "Would you use the factory again?" (yes/no/maybe) | ||
| Store in spec file metrics section. These feed self-instrumentation (concern #9). | ||
| ### 6. Save Final State | ||
| Update spec file: | ||
| - progress.validate = "complete" | ||
| - metrics populated | ||
| ### 7. Next Steps | ||
| Display guidance: | ||
| > "Your team **{team_name}** is ready! Here's what to do next: | ||
| > | ||
| > 1. **Review generated files** — Check agent personas, workflow steps, and contract schemas | ||
| > 2. **Fill in domain content** — The factory created structural skeletons; add your domain expertise | ||
| > 3. **Run `convoke-doctor`** — Verify the team passes framework-level validation | ||
| > 4. **Test with a real workflow** — Invoke one of your agents and run through a workflow | ||
| > 5. **Iterate** — Use the factory's Add Agent (Phase 3) to extend your team later | ||
| > | ||
| > Spec file saved at: `_bmad-output/planning-artifacts/team-spec-{team}.yaml` | ||
| > This file is your audit trail and can be used with Express Mode to recreate the team." | ||
| ### 8. Abort Path (If Requested) | ||
| If the contributor requests abort at any point: | ||
| - Display the file manifest: "The following files were created during this factory run:" | ||
| - List each file with its path | ||
| - Provide removal instructions: "To remove all generated files, delete the following paths:" | ||
| - Note: "The spec file at {path} will be preserved for your records." | ||
| ## Visibility Checklist — Step 5 | ||
| Colleague sees: | ||
| - [ ] Validation results summary (pass/fail per check) | ||
| - [ ] Complete file manifest | ||
| - [ ] Next steps guidance | ||
| Runs silently: | ||
| - [ ] End-to-end validation suite | ||
| - [ ] Regression check on existing teams | ||
| - [ ] Manifest generation | ||
| Concept count: 2/3 (validation results, next steps) | ||
| Approval prompt: N/A — this is the final step |
| # Step 00: Intent Routing | ||
| ## Purpose | ||
| Classify the contributor's intent and route to the correct factory workflow. | ||
| This step is LLM-heavy — a colleague saying "I want to automate onboarding" requires reasoning about whether that's a team, an agent addition, or a skill addition. | ||
| ## Execution | ||
| ### Mode Detection | ||
| Check if this was invoked with data context: | ||
| - **data="resume"** → Load existing spec file, run `spec-differ.js` to find resume point, jump to that step | ||
| - **data="express"** → Ask for spec file path, load it, validate with `spec-parser.js`, jump to step-03-review | ||
| - **No data** → Proceed with intent classification below | ||
| ### Intent Classification | ||
| Ask the contributor what they want to do. Listen for natural language descriptions and classify: | ||
| | Intent Signal | Route To | | ||
| |---------------|----------| | ||
| | "Create a new team", "build a team", "add a team module" | **add-team** workflow → step-01-scope.md | | ||
| | "Add an agent to [existing team]", "extend [team] with a new agent" | **add-agent** workflow (Phase 3 — not yet available) | | ||
| | "Add a skill/workflow to [agent]", "extend [agent]" | **add-skill** workflow (Phase 3 — not yet available) | | ||
| | Unclear / doesn't match | Ask clarifying question: "Are you looking to create an entirely new team, add an agent to an existing team, or add a workflow to an existing agent?" | | ||
| ### For "Create Team" (Primary Flow) | ||
| 1. Confirm the contributor wants to create a new team | ||
| 2. Brief them on what the factory will do: | ||
| > "We'll walk through 5 steps together: | ||
| > 1. **Scope** — Define your team's agents, their roles, and how they work together | ||
| > 2. **Connect** — Design integration wiring (contracts, config, routing) | ||
| > 3. **Review** — Summary of all decisions for your approval | ||
| > 4. **Generate** — Create all files via BMB delegation + integration wiring | ||
| > 5. **Validate** — End-to-end validation confirming everything works | ||
| > | ||
| > At any point you can save progress and resume later." | ||
| 3. Proceed to: `{project-root}/_bmad/bme/_team-factory/workflows/add-team/step-01-scope.md` | ||
| ### For Phase 3 Workflows (Not Yet Available) | ||
| Display: | ||
| > "Add Agent and Add Skill workflows are planned for Phase 3 of the Team Factory. For now, you can: | ||
| > 1. Use the Architecture Reference ([AR] from the menu) to understand what's needed | ||
| > 2. Use BMB (Bond) to generate individual agent or workflow files | ||
| > 3. Manually wire integration following the reference checklist" | ||
| ### Resume Mode | ||
| 1. Ask for the path to an existing `team-spec-*.yaml` file | ||
| 2. Run: `node {project-root}/_bmad/bme/_team-factory/lib/spec-differ.js` logic: | ||
| - Parse the spec file | ||
| - Find the first non-complete step in the progress section | ||
| - Display: "Found spec for team **{team_name}**. Progress: {completed steps}. Resuming from **{resume_step}**." | ||
| 3. Jump to the appropriate step file | ||
| ### Express Mode | ||
| 1. Ask for the path to a completed `team-spec-*.yaml` file | ||
| 2. Run: `node {project-root}/_bmad/bme/_team-factory/lib/spec-parser.js` logic: | ||
| - Parse and validate the spec file against the appropriate schema | ||
| - If validation fails: display errors, ask contributor to fix the spec file | ||
| - If validation passes: display decision summary and jump to step-03-review.md | ||
| ## Visibility Checklist — Step 0 | ||
| Colleague sees: | ||
| - [ ] Intent classification question or mode confirmation | ||
| - [ ] Brief overview of factory steps (for Create Team) | ||
| - [ ] Resume point summary (for Resume mode) | ||
| Runs silently: | ||
| - [ ] Spec file parsing and validation (Resume/Express) | ||
| - [ ] Resume point detection (Resume) | ||
| Concept count: 2/3 | ||
| Approval prompt: "Ready to start creating your team?" |
| #!/usr/bin/env node | ||
| /** | ||
| * catalog-generator.js — Story sp-3-1 | ||
| * | ||
| * Generates a decision-tree catalog README from skill-manifest.csv and | ||
| * agent-manifest.csv. The catalog is organized by user intent ("I need to...") | ||
| * so consultants new to agentic tools can find the right skill quickly. | ||
| * | ||
| * Usage: | ||
| * node scripts/portability/catalog-generator.js # stdout | ||
| * node scripts/portability/catalog-generator.js --output <path> | ||
| * node scripts/portability/catalog-generator.js --help | ||
| * | ||
| * Read-only on the source tree — reads two CSV manifests, writes one file | ||
| * (or stdout). | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { findProjectRoot } = require('../update/lib/utils'); | ||
| const { readManifest } = require('./manifest-csv'); | ||
| const { resolvePersonaSummary, loadAgentManifest } = require('./export-engine'); | ||
| // ============================================================================= | ||
| // CONSTANTS | ||
| // ============================================================================= | ||
| const INTENT_TO_HEADING = { | ||
| 'think-through-problem': 'I need to think through a problem', | ||
| 'define-what-to-build': 'I need to define what to build', | ||
| 'review-something': 'I need to review something', | ||
| 'write-documentation': 'I need to write documentation', | ||
| 'plan-your-work': 'I need to plan my work', | ||
| 'test-your-code': 'I need to test my code', | ||
| 'discover-product-fit': 'I need to discover product-market fit', | ||
| 'assess-readiness': 'I need to assess production readiness', | ||
| 'meta-platform': null, // excluded from catalog | ||
| }; | ||
| const TIER_BADGES = { | ||
| standalone: '**✅ Ready to use**', | ||
| 'light-deps': '**📦 Needs setup**', | ||
| pipeline: '**🔒 Framework only** (Requires full Convoke installation)', | ||
| }; | ||
| // Intent display order: standalone-heavy intents first, pipeline-only last | ||
| const INTENT_ORDER = [ | ||
| 'think-through-problem', | ||
| 'define-what-to-build', | ||
| 'review-something', | ||
| 'write-documentation', | ||
| 'plan-your-work', | ||
| 'test-your-code', | ||
| 'discover-product-fit', | ||
| 'assess-readiness', | ||
| ]; | ||
| // ============================================================================= | ||
| // HELPERS | ||
| // ============================================================================= | ||
| /** | ||
| * Truncate description to first sentence (period + space) or 120 chars. | ||
| */ | ||
| function truncateDescription(desc) { | ||
| if (!desc) return ''; | ||
| const periodIdx = desc.indexOf('. '); | ||
| if (periodIdx > 0 && periodIdx < 120) { | ||
| return desc.slice(0, periodIdx + 1); | ||
| } | ||
| if (desc.length <= 120) return desc; | ||
| return desc.slice(0, 120) + '...'; | ||
| } | ||
| /** | ||
| * Render a single skill entry line. | ||
| */ | ||
| function renderSkillEntry(skill) { | ||
| const icon = skill.icon || '🔧'; | ||
| const badge = TIER_BADGES[skill.tier] || skill.tier; | ||
| const desc = truncateDescription(skill.description); | ||
| const link = `[→ instructions](./${skill.name}/)`; | ||
| return `- ${icon} **${skill.personaName}** — ${desc} ${badge} ${link}`; | ||
| } | ||
| // ============================================================================= | ||
| // CATALOG GENERATION | ||
| // ============================================================================= | ||
| function generateCatalog(projectRoot) { | ||
| // Load manifests | ||
| const skillManifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const { header, rows } = readManifest(skillManifestPath); | ||
| const agents = loadAgentManifest(projectRoot); | ||
| const nameIdx = header.indexOf('name'); | ||
| const tierIdx = header.indexOf('tier'); | ||
| const intentIdx = header.indexOf('intent'); | ||
| const descIdx = header.indexOf('description'); | ||
| // Dedupe by skill name (first row wins) | ||
| const seen = new Set(); | ||
| const skills = []; | ||
| for (const row of rows) { | ||
| const name = row[nameIdx]; | ||
| if (seen.has(name)) continue; | ||
| seen.add(name); | ||
| const intent = row[intentIdx] || 'unknown'; | ||
| const tier = row[tierIdx] || 'unknown'; | ||
| // Skip meta-platform | ||
| if (intent === 'meta-platform') continue; | ||
| const persona = resolvePersonaSummary(name, agents); | ||
| skills.push({ | ||
| name, | ||
| tier, | ||
| intent, | ||
| description: row[descIdx] || '', | ||
| personaName: persona.name, | ||
| icon: persona.icon, | ||
| }); | ||
| } | ||
| // Count by tier | ||
| const standaloneCount = skills.filter((s) => s.tier === 'standalone').length; | ||
| const lightDepsCount = skills.filter((s) => s.tier === 'light-deps').length; | ||
| const pipelineCount = skills.filter((s) => s.tier === 'pipeline').length; | ||
| const mainBodyCount = standaloneCount + lightDepsCount; | ||
| // Group by intent | ||
| const byIntent = {}; | ||
| for (const skill of skills) { | ||
| if (!byIntent[skill.intent]) byIntent[skill.intent] = []; | ||
| byIntent[skill.intent].push(skill); | ||
| } | ||
| // Sort skills alphabetically within each intent | ||
| for (const intent of Object.keys(byIntent)) { | ||
| byIntent[intent].sort((a, b) => a.name.localeCompare(b.name)); | ||
| } | ||
| // Warn on unknown intents | ||
| for (const intent of Object.keys(byIntent)) { | ||
| if (!(intent in INTENT_TO_HEADING)) { | ||
| process.stderr.write(`Warning: unknown intent "${intent}" — using fallback heading\n`); | ||
| } | ||
| } | ||
| // === Render === | ||
| const lines = []; | ||
| // Header | ||
| lines.push('# Convoke Skills Catalog'); | ||
| lines.push(''); | ||
| lines.push('> Find the right AI skill for your task. Browse by what you\'re trying to do.'); | ||
| lines.push(''); | ||
| lines.push('## How to use a skill'); | ||
| lines.push(''); | ||
| lines.push('1. Find your intent below — what are you trying to do?'); | ||
| lines.push('2. Pick a skill marked **✅ Ready to use**'); | ||
| lines.push('3. Copy the skill folder into your project\'s `.claude/skills/` directory'); | ||
| lines.push(''); | ||
| lines.push('### Tier legend'); | ||
| lines.push(''); | ||
| lines.push('- **✅ Ready to use** — standalone skill, just copy and go'); | ||
| lines.push('- **📦 Needs setup** — includes templates/config that need minor configuration'); | ||
| lines.push('- **🔒 Framework only** — requires full Convoke installation'); | ||
| lines.push(''); | ||
| lines.push( | ||
| `**${mainBodyCount} skills in this catalog** (${standaloneCount} ready to use, ${lightDepsCount} need setup) | ${pipelineCount} framework-only skills listed below` | ||
| ); | ||
| lines.push(''); | ||
| lines.push('---'); | ||
| lines.push(''); | ||
| // Main body: Tier 1 + Tier 2 skills grouped by intent | ||
| for (const intent of INTENT_ORDER) { | ||
| const heading = INTENT_TO_HEADING[intent]; | ||
| if (!heading) continue; // meta-platform excluded | ||
| const intentSkills = (byIntent[intent] || []).filter( | ||
| (s) => s.tier === 'standalone' || s.tier === 'light-deps' | ||
| ); | ||
| if (intentSkills.length === 0) continue; | ||
| lines.push(`## ${heading}`); | ||
| lines.push(''); | ||
| for (const skill of intentSkills) { | ||
| lines.push(renderSkillEntry(skill)); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| // Render any unknown-intent skills with fallback heading (spec AC #2) | ||
| for (const intent of Object.keys(byIntent)) { | ||
| if (intent in INTENT_TO_HEADING) continue; // already rendered above | ||
| const intentSkills = byIntent[intent].filter( | ||
| (s) => s.tier === 'standalone' || s.tier === 'light-deps' | ||
| ); | ||
| if (intentSkills.length === 0) continue; | ||
| lines.push(`## I need to: ${intent}`); | ||
| lines.push(''); | ||
| for (const skill of intentSkills) { | ||
| lines.push(renderSkillEntry(skill)); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| // Collapsed section: Tier 3 pipeline skills | ||
| const pipelineSkills = skills.filter((s) => s.tier === 'pipeline'); | ||
| if (pipelineSkills.length > 0) { | ||
| lines.push('---'); | ||
| lines.push(''); | ||
| lines.push('<details>'); | ||
| lines.push('<summary><strong>Framework-only skills (requires Convoke installation)</strong></summary>'); | ||
| lines.push(''); | ||
| // Group pipeline skills by intent too | ||
| const pipelineByIntent = {}; | ||
| for (const skill of pipelineSkills) { | ||
| if (!pipelineByIntent[skill.intent]) pipelineByIntent[skill.intent] = []; | ||
| pipelineByIntent[skill.intent].push(skill); | ||
| } | ||
| for (const intent of INTENT_ORDER) { | ||
| const heading = INTENT_TO_HEADING[intent]; | ||
| if (!heading) continue; | ||
| const intentSkills = pipelineByIntent[intent] || []; | ||
| if (intentSkills.length === 0) continue; | ||
| lines.push(`### ${heading}`); | ||
| lines.push(''); | ||
| for (const skill of intentSkills) { | ||
| lines.push(renderSkillEntry(skill)); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| // Also render unknown-intent pipeline skills | ||
| for (const intent of Object.keys(pipelineByIntent)) { | ||
| if (intent in INTENT_TO_HEADING) continue; | ||
| const unknownSkills = pipelineByIntent[intent] || []; | ||
| if (unknownSkills.length === 0) continue; | ||
| lines.push(`### I need to: ${intent}`); | ||
| lines.push(''); | ||
| for (const skill of unknownSkills) { | ||
| lines.push(renderSkillEntry(skill)); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| lines.push('</details>'); | ||
| lines.push(''); | ||
| } | ||
| // Footer | ||
| lines.push('---'); | ||
| lines.push(''); | ||
| lines.push(`*Generated by \`convoke-export\` — do not edit manually.*`); | ||
| lines.push(`*Generated on ${new Date().toISOString().slice(0, 10)}.*`); | ||
| lines.push(''); | ||
| lines.push('For the full Convoke framework, see the [Convoke Agents repository](https://github.com/amalik/convoke-agents).'); | ||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
| // ============================================================================= | ||
| // CLI | ||
| // ============================================================================= | ||
| function printHelp() { | ||
| process.stdout.write( | ||
| [ | ||
| 'Usage: catalog-generator [options]', | ||
| '', | ||
| 'Generate a decision-tree skill catalog README from manifest data.', | ||
| '', | ||
| 'Options:', | ||
| ' --output <path> Write catalog to file (default: stdout)', | ||
| ' --help, -h Show this help message', | ||
| '', | ||
| 'Exit codes:', | ||
| ' 0 Success', | ||
| ' 1 Usage error', | ||
| ' 2 Manifest read failure', | ||
| '', | ||
| ].join('\n') | ||
| ); | ||
| } | ||
| function main() { | ||
| const argv = process.argv.slice(2); | ||
| if (argv.includes('--help') || argv.includes('-h')) { | ||
| printHelp(); | ||
| return 0; | ||
| } | ||
| let outputPath = null; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| if (argv[i] === '--output') { | ||
| outputPath = argv[++i]; | ||
| if (!outputPath) { | ||
| process.stderr.write('Error: --output requires a path argument\n'); | ||
| return 1; | ||
| } | ||
| } else if (argv[i].startsWith('--')) { | ||
| process.stderr.write(`Unknown flag: ${argv[i]}. Run --help for usage.\n`); | ||
| return 1; | ||
| } | ||
| } | ||
| let projectRoot; | ||
| try { | ||
| projectRoot = findProjectRoot(); | ||
| } catch (e) { | ||
| process.stderr.write(`Error: could not find project root — ${e.message}\n`); | ||
| return 2; | ||
| } | ||
| let catalog; | ||
| try { | ||
| catalog = generateCatalog(projectRoot); | ||
| } catch (e) { | ||
| process.stderr.write(`Error generating catalog: ${e.message}\n`); | ||
| return 2; | ||
| } | ||
| if (outputPath) { | ||
| try { | ||
| const dir = path.dirname(outputPath); | ||
| fs.mkdirSync(dir, { recursive: true }); | ||
| fs.writeFileSync(outputPath, catalog); | ||
| } catch (e) { | ||
| process.stderr.write(`Error writing catalog: ${e.message}\n`); | ||
| return 2; | ||
| } | ||
| } else { | ||
| process.stdout.write(catalog); | ||
| } | ||
| return 0; | ||
| } | ||
| if (require.main === module) { | ||
| process.exit(main()); | ||
| } | ||
| module.exports = { generateCatalog, main }; |
| #!/usr/bin/env node | ||
| /** | ||
| * classify-skills.js — Story sp-1-2 | ||
| * | ||
| * Reads `_bmad/_config/skill-manifest.csv`, classifies each skill with tier, | ||
| * intent, and exporter-essential dependencies, and writes the manifest back. | ||
| * | ||
| * Usage: | ||
| * node scripts/portability/classify-skills.js # write | ||
| * node scripts/portability/classify-skills.js --dry-run # preview only | ||
| * node scripts/portability/classify-skills.js --force # override conflicts | ||
| * | ||
| * Idempotent: re-running with no source changes produces zero diff. | ||
| * Non-destructive: preserves manual classification overrides unless --force. | ||
| * | ||
| * See _bmad-output/implementation-artifacts/sp-1-2-classify-all-skills.md | ||
| * for the full classification policy and heuristics. | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { findProjectRoot } = require('../update/lib/utils'); | ||
| const { readManifest, writeManifest } = require('./manifest-csv'); | ||
| // ============================================================================= | ||
| // CONSTANTS — locked classification rules from sp-1-2 spec (AC #7, Task 3) | ||
| // ============================================================================= | ||
| const VALID_TIERS = ['standalone', 'light-deps', 'pipeline']; | ||
| const VALID_INTENTS = [ | ||
| 'think-through-problem', | ||
| 'define-what-to-build', | ||
| 'review-something', | ||
| 'write-documentation', | ||
| 'plan-your-work', | ||
| 'test-your-code', | ||
| 'discover-product-fit', | ||
| 'assess-readiness', | ||
| 'meta-platform', | ||
| ]; | ||
| // Canonical meta-platform skills (AC #7) — framework-internal, NOT portable. | ||
| // | ||
| // Note: AC #7 originally listed `bmad-agent-bme-team-factory` (Loom Master) as | ||
| // the 6th meta-platform skill, but that name lives in the AGENT manifest, not | ||
| // the skill manifest. There is no team-factory entry in skill-manifest.csv to | ||
| // classify, so the meta-platform set has 5 skills, not 6. Loom Master is | ||
| // invoked via party-mode and the team-factory agent file directly. | ||
| const META_PLATFORM_SKILLS = new Set([ | ||
| 'bmad-init', | ||
| 'bmad-help', | ||
| 'bmad-party-mode', | ||
| 'bmad-builder-setup', | ||
| 'bmad-agent-builder', | ||
| ]); | ||
| // Standalone utilities explicitly carved out from meta-platform (AC #7). | ||
| const STANDALONE_UTILITY_INTENTS = { | ||
| 'bmad-distillator': 'write-documentation', | ||
| 'bmad-advanced-elicitation': 'think-through-problem', | ||
| 'bmad-shard-doc': 'write-documentation', | ||
| 'bmad-index-docs': 'write-documentation', | ||
| // bmad-generate-project-context handled separately — Tier 2, write-documentation | ||
| }; | ||
| // Pipeline skills by name (Task 2 priority 1). | ||
| const PIPELINE_BY_NAME = new Set([ | ||
| 'bmad-dev-story', | ||
| 'bmad-sprint-planning', | ||
| 'bmad-sprint-status', | ||
| 'bmad-create-story', | ||
| 'bmad-correct-course', | ||
| 'bmad-retrospective', | ||
| 'bmad-code-review', | ||
| 'bmad-check-implementation-readiness', | ||
| 'bmad-validate-prd', | ||
| 'bmad-edit-prd', | ||
| ]); | ||
| // Persona-only bmad-agent-* skills, enumerated (Task 3, no judgment calls). | ||
| const PERSONA_AGENT_INTENTS = { | ||
| 'bmad-agent-analyst': 'define-what-to-build', | ||
| 'bmad-agent-pm': 'define-what-to-build', | ||
| 'bmad-agent-architect': 'define-what-to-build', | ||
| 'bmad-agent-ux-designer': 'define-what-to-build', | ||
| 'bmad-agent-tech-writer': 'write-documentation', | ||
| 'bmad-agent-sm': 'plan-your-work', | ||
| 'bmad-agent-dev': 'plan-your-work', | ||
| 'bmad-agent-quick-flow-solo-dev': 'plan-your-work', | ||
| 'bmad-agent-qa': 'test-your-code', | ||
| // bmad-agent-builder and bmad-agent-bme-team-factory are in META_PLATFORM_SKILLS above. | ||
| }; | ||
| // Vortex 7 stream agents (discover-product-fit). | ||
| const VORTEX_AGENTS = new Set([ | ||
| 'bmad-agent-bme-contextualization-expert', | ||
| 'bmad-agent-bme-discovery-empathy-expert', | ||
| 'bmad-agent-bme-research-convergence-specialist', | ||
| 'bmad-agent-bme-hypothesis-engineer', | ||
| 'bmad-agent-bme-lean-experiments-specialist', | ||
| 'bmad-agent-bme-production-intelligence-specialist', | ||
| 'bmad-agent-bme-learning-decision-expert', | ||
| ]); | ||
| // Gyre 4 readiness agents (assess-readiness). | ||
| const GYRE_AGENTS = new Set([ | ||
| 'bmad-agent-bme-stack-detective', | ||
| 'bmad-agent-bme-model-curator', | ||
| 'bmad-agent-bme-readiness-analyst', | ||
| 'bmad-agent-bme-review-coach', | ||
| ]); | ||
| // Note: universal config vars (user_name, output_folder, etc.) are stripped | ||
| // implicitly by extractDependencies — we only emit deps for templates, | ||
| // sidecars, and chained skills. Config vars are never extracted. | ||
| // ============================================================================= | ||
| // CLASSIFICATION HEURISTICS | ||
| // ============================================================================= | ||
| /** | ||
| * Determine intent from skill name + path. Returns null if no match. | ||
| * | ||
| * Order matters — more specific matches first. | ||
| */ | ||
| function classifyIntent(name, _modulePath) { | ||
| // 1. Meta-platform (locked set) | ||
| if (META_PLATFORM_SKILLS.has(name)) return 'meta-platform'; | ||
| // 2. Standalone utilities carved out from meta-platform | ||
| if (name in STANDALONE_UTILITY_INTENTS) return STANDALONE_UTILITY_INTENTS[name]; | ||
| if (name === 'bmad-generate-project-context') return 'write-documentation'; | ||
| // 3. Persona-only agent skills (enumerated) | ||
| if (name in PERSONA_AGENT_INTENTS) return PERSONA_AGENT_INTENTS[name]; | ||
| // 4. Vortex / Gyre agent skills | ||
| if (VORTEX_AGENTS.has(name)) return 'discover-product-fit'; | ||
| if (GYRE_AGENTS.has(name)) return 'assess-readiness'; | ||
| // 5. Pattern-matched intent groups | ||
| if ( | ||
| name.startsWith('bmad-cis-agent-') || | ||
| name === 'bmad-brainstorming' || | ||
| name === 'bmad-cis-problem-solving' || | ||
| name === 'bmad-cis-design-thinking' || | ||
| name === 'bmad-cis-storytelling' || | ||
| name === 'bmad-cis-innovation-strategy' | ||
| ) { | ||
| return 'think-through-problem'; | ||
| } | ||
| if ( | ||
| [ | ||
| 'bmad-create-prd', | ||
| 'bmad-edit-prd', | ||
| 'bmad-validate-prd', | ||
| 'bmad-product-brief', | ||
| 'bmad-create-ux-design', | ||
| 'bmad-create-architecture', | ||
| 'bmad-create-epics-and-stories', | ||
| 'bmad-domain-research', | ||
| 'bmad-market-research', | ||
| 'bmad-technical-research', | ||
| ].includes(name) | ||
| ) { | ||
| return 'define-what-to-build'; | ||
| } | ||
| if ( | ||
| name === 'bmad-code-review' || | ||
| name.startsWith('bmad-review-') || | ||
| name.startsWith('bmad-editorial-review-') | ||
| ) { | ||
| return 'review-something'; | ||
| } | ||
| if ( | ||
| [ | ||
| 'bmad-document-project', | ||
| 'bmad-generate-project-context', | ||
| 'bmad-index-docs', | ||
| ].includes(name) || | ||
| name.startsWith('bmad-tech-writer') | ||
| ) { | ||
| return 'write-documentation'; | ||
| } | ||
| if ( | ||
| name === 'bmad-create-story' || | ||
| name === 'bmad-correct-course' || | ||
| name === 'bmad-retrospective' || | ||
| name === 'bmad-dev-story' || | ||
| name === 'bmad-quick-dev' || | ||
| name.startsWith('bmad-sprint-') | ||
| ) { | ||
| return 'plan-your-work'; | ||
| } | ||
| if ( | ||
| name.startsWith('bmad-testarch-') || | ||
| name === 'bmad-qa-generate-e2e-tests' || | ||
| name === 'bmad-teach-me-testing' | ||
| ) { | ||
| return 'test-your-code'; | ||
| } | ||
| if (name === 'bmad-check-implementation-readiness') return 'assess-readiness'; | ||
| // 6. WDS skills — all phases produce specs/scenarios → define-what-to-build | ||
| if (name.startsWith('wds-')) return 'define-what-to-build'; | ||
| // 7. tea / workflow-builder edge cases | ||
| if (name === 'bmad-tea') return 'test-your-code'; | ||
| if (name === 'bmad-workflow-builder') return 'meta-platform'; | ||
| // 8. AG Epic 6 governance skills — framework-internal artifact governance tooling | ||
| if (name === 'bmad-migrate-artifacts' || name === 'bmad-portfolio-status') { | ||
| return 'meta-platform'; | ||
| } | ||
| // 9. Enhance module skills — initiatives-backlog manages a planning artifact | ||
| if (name === 'bmad-enhance-initiatives-backlog') return 'plan-your-work'; | ||
| return null; | ||
| } | ||
| /** | ||
| * Read a skill's source files (SKILL.md + referenced workflow + step files) | ||
| * up to a recursion depth limit. Returns concatenated text content. | ||
| */ | ||
| function readSkillContent(projectRoot, relativePath, maxDepth = 3) { | ||
| const visited = new Set(); | ||
| const chunks = []; | ||
| // Project root must end with separator so the prefix check is unambiguous | ||
| const rootWithSep = projectRoot.endsWith(path.sep) | ||
| ? projectRoot | ||
| : projectRoot + path.sep; | ||
| /** | ||
| * Containment guard: only follow refs that resolve inside projectRoot. | ||
| * Defends against malicious/buggy markdown refs with `../../../etc/passwd`. | ||
| */ | ||
| function isInsideProjectRoot(absPath) { | ||
| return absPath === projectRoot || absPath.startsWith(rootWithSep); | ||
| } | ||
| function readOne(absPath, depth) { | ||
| if (depth > maxDepth) return; | ||
| if (!isInsideProjectRoot(absPath)) return; // P1: containment guard | ||
| if (visited.has(absPath)) return; | ||
| visited.add(absPath); | ||
| if (!fs.existsSync(absPath)) return; | ||
| let content; | ||
| try { | ||
| content = fs.readFileSync(absPath, 'utf8'); | ||
| } catch (e) { | ||
| return; | ||
| } | ||
| chunks.push(content); | ||
| // Find referenced files: workflow.md, steps/*.md, ./templates/*.md, etc. | ||
| const dir = path.dirname(absPath); | ||
| const refs = []; | ||
| // Relative refs (./...md or ../...md or steps/...md) | ||
| const relRegex = /(?:\.\/|\.\.\/|steps\/)[\w./-]+\.md/g; | ||
| let m; | ||
| while ((m = relRegex.exec(content)) !== null) { | ||
| refs.push(path.resolve(dir, m[0])); | ||
| } | ||
| // Project-root refs (support both {project-root} and {project_root}) | ||
| const projRegex = /\{project[-_]root\}\/([\w./-]+\.md)/g; | ||
| while ((m = projRegex.exec(content)) !== null) { | ||
| refs.push(path.join(projectRoot, m[1])); | ||
| } | ||
| // Bare _bmad/ refs in load directives | ||
| const bmadRegex = /(?:Load[^:]*:|read fully and follow:?)[^\n]*?(_bmad\/[\w./-]+\.md)/gi; | ||
| while ((m = bmadRegex.exec(content)) !== null) { | ||
| refs.push(path.join(projectRoot, m[1])); | ||
| } | ||
| for (const ref of refs) { | ||
| // Each readOne call re-checks containment; this loop just enqueues. | ||
| readOne(ref, depth + 1); | ||
| } | ||
| } | ||
| readOne(path.join(projectRoot, relativePath), 0); | ||
| return chunks.join('\n'); | ||
| } | ||
| /** | ||
| * Determine tier from skill name + workflow content. | ||
| */ | ||
| function classifyTier(name, modulePath, content, intent) { | ||
| // Persona-only agent skills (bmad-agent-pm, bmad-agent-sm, etc.) are | ||
| // menu wrappers around their persona — they LIST other skills as menu | ||
| // options but don't actively chain them. They are standalone personas. | ||
| // Override applies BEFORE pipeline detection to prevent the menu list | ||
| // from triggering "invokes another skill" pipeline detection. | ||
| if (name in PERSONA_AGENT_INTENTS) { | ||
| return 'standalone'; | ||
| } | ||
| // Pipeline indicators (Tier 3) — first match wins. | ||
| if (PIPELINE_BY_NAME.has(name)) return 'pipeline'; | ||
| if (intent === 'meta-platform') return 'pipeline'; | ||
| if (modulePath.startsWith('_bmad/wds/') || name.startsWith('wds-')) return 'pipeline'; | ||
| if (modulePath.includes('_bmad/bme/_vortex/')) return 'pipeline'; | ||
| if (modulePath.includes('_bmad/bme/_gyre/')) return 'pipeline'; | ||
| if (content) { | ||
| // Consumes prior artifacts | ||
| if (/\{implementation_artifacts\}\/[^}\s]*\.md/.test(content)) return 'pipeline'; | ||
| // P4 (sp-1-2 review): tightened "invokes another skill" detection. | ||
| // Old regex matched any prose mentioning "Skill tool" — too broad. | ||
| // New regex requires: | ||
| // - An XML-style <Skill ...> activation tag, OR | ||
| // - A YAML/frontmatter `skill:` key with bmad- value, OR | ||
| // - An explicit `Skill tool` invocation phrase paired with a tool action verb | ||
| // (use|invoke|call|launch) within the same line | ||
| if ( | ||
| /<Skill\b/.test(content) || | ||
| /^\s*skill:\s*['"]?bmad-/im.test(content) || | ||
| /(?:use|invoke|call|launch)[^.\n]{0,40}\bSkill tool\b/i.test(content) | ||
| ) { | ||
| return 'pipeline'; | ||
| } | ||
| } | ||
| // Light-deps indicators (Tier 2) | ||
| if (content) { | ||
| if (/^templateFile:/m.test(content)) return 'light-deps'; | ||
| // Match both absolute (_bmad/foo/templates/) and relative (../templates/, ./templates/) forms | ||
| if (/(?:^|[\s`'"(/])(?:\.{1,2}\/)?[\w/-]*templates\/[\w./-]+\.(?:md|yaml|json)/.test(content)) { | ||
| return 'light-deps'; | ||
| } | ||
| if (/_bmad\/_memory\/[\w./-]+/.test(content)) return 'light-deps'; | ||
| // Explicit "Load template:" directive | ||
| if (/Load template:/i.test(content)) return 'light-deps'; | ||
| } | ||
| // Tier 2 carve-out: bmad-generate-project-context | ||
| if (name === 'bmad-generate-project-context') return 'light-deps'; | ||
| // Default: Tier 1 | ||
| return 'standalone'; | ||
| } | ||
| /** | ||
| * Extract exporter-essential dependencies from a skill's content. | ||
| * Returns a sorted, deduplicated array of dependency strings. | ||
| * | ||
| * @param {string} name Skill name (excluded from its own deps) | ||
| * @param {string} content Concatenated skill source | ||
| * @param {Set<string>} validSkillNames Whitelist of canonical skill IDs (filters out hook scripts, link text, etc.) | ||
| */ | ||
| function extractDependencies(name, content, validSkillNames) { | ||
| if (!content) return []; | ||
| const deps = new Set(); | ||
| // 1. Templates — match both absolute (_bmad/foo/templates/) and relative (../templates/) forms. | ||
| // We normalize relative paths to absolute repo paths only when we can — for now, just record | ||
| // the matched path as-is and let Story 1.3 validate resolution. | ||
| const absTemplateRegex = /(_bmad\/[^/\s]+\/templates\/[\w./-]+\.(?:md|yaml|json))/g; | ||
| let m; | ||
| while ((m = absTemplateRegex.exec(content)) !== null) { | ||
| deps.add(m[1]); | ||
| } | ||
| // Relative templates — we can't reliably resolve to absolute without knowing the source dir, | ||
| // so we record a relative-template marker. This is intentionally lossy until Story 1.3. | ||
| const relTemplateRegex = /(?:^|[\s`'"(])(\.{1,2}\/[\w/-]*templates\/[\w./-]+\.(?:md|yaml|json))/gm; | ||
| while ((m = relTemplateRegex.exec(content)) !== null) { | ||
| deps.add(m[1]); | ||
| } | ||
| // 2. Sidecars (_bmad/_memory/...) | ||
| const sidecarRegex = /(_bmad\/_memory\/[\w./-]+)/g; | ||
| while ((m = sidecarRegex.exec(content)) !== null) { | ||
| const cleaned = m[1].replace(/[.,;:`'")\]]+$/, ''); | ||
| if (cleaned) deps.add(cleaned); | ||
| } | ||
| // 3. Chained skills — match candidate patterns, then filter against the canonical | ||
| // set of valid skill names. This eliminates false positives from hook scripts | ||
| // (`bmad-speak`), markdown link text (`WDS-SPECIFICATION-PATTERN`), and | ||
| // hyphenation false-matches (`bmad-quick-dev-new-preview`). | ||
| // | ||
| // P4 (sp-1-2 review): also strip URL-context matches. The whitelist filter | ||
| // catches unknown names, but a URL like https://github.com/.../bmad-dev-story | ||
| // contains a real skill name. Remove URLs from the search space first. | ||
| const contentNoUrls = content.replace(/https?:\/\/\S+/g, ' '); | ||
| const skillRegex = /\b((?:bmad|wds)-[a-z][a-z0-9-]+)\b/g; | ||
| while ((m = skillRegex.exec(contentNoUrls)) !== null) { | ||
| const skillName = m[1]; | ||
| if (skillName === name) continue; // self-reference | ||
| if (skillName === 'bmad-init') continue; // universal — handled centrally | ||
| if (validSkillNames && !validSkillNames.has(skillName)) continue; // not a real skill | ||
| deps.add(skillName); | ||
| } | ||
| return Array.from(deps).sort(); | ||
| } | ||
| /** | ||
| * Classify a single skill row. Returns { tier, intent, dependencies } proposal. | ||
| * | ||
| * @param {string[]} row Row fields | ||
| * @param {string[]} header Column names | ||
| * @param {string} projectRoot Absolute project root | ||
| * @param {Set<string>} validSkillNames Whitelist of canonical skill IDs for dep filtering | ||
| */ | ||
| function classifyRow(row, header, projectRoot, validSkillNames) { | ||
| const idx = (col) => header.indexOf(col); | ||
| const name = row[idx('name')]; | ||
| const modulePath = row[idx('path')]; | ||
| const intent = classifyIntent(name, modulePath); | ||
| const content = readSkillContent(projectRoot, modulePath); | ||
| const tier = classifyTier(name, modulePath, content, intent); | ||
| // Persona-only agent skills are menu wrappers — they list other skills as | ||
| // menu options, but those are not exporter dependencies. The exporter only | ||
| // needs the persona file itself. Skip dep extraction for them. | ||
| const dependencies = | ||
| name in PERSONA_AGENT_INTENTS | ||
| ? [] | ||
| : extractDependencies(name, content, validSkillNames); | ||
| // P3 (sp-1-2 review): unknown intent must NOT silently default to | ||
| // meta-platform — that would mark new/custom skills as framework-internal | ||
| // without warning. Return null to signal "no proposal" so main() can | ||
| // preserve the existing value (if any) and route the row to BORDERLINE.md | ||
| // as a heuristic miss for human review. | ||
| return { | ||
| tier, | ||
| intent, // null when no heuristic matched — main() handles the miss | ||
| dependencies: dependencies.join(';'), | ||
| intentMatched: intent !== null, | ||
| }; | ||
| } | ||
| // ============================================================================= | ||
| // BORDERLINE.md GENERATION | ||
| // ============================================================================= | ||
| function renderBorderlineMd(date, conflicts, ambiguous, misses) { | ||
| const lines = [ | ||
| '# Portability Classification — Borderline Cases', | ||
| '', | ||
| `Generated by \`scripts/portability/classify-skills.js\` on ${date}.`, | ||
| '', | ||
| '## Manual Override Protected', | ||
| '', | ||
| ]; | ||
| if (conflicts.length === 0) { | ||
| lines.push('_None._', ''); | ||
| } else { | ||
| lines.push('| Skill | Existing | Proposed | Reason |'); | ||
| lines.push('|-------|----------|----------|--------|'); | ||
| for (const c of conflicts) { | ||
| const existing = `tier=${c.existing.tier} intent=${c.existing.intent} deps=${c.existing.dependencies || '(empty)'}`; | ||
| const proposed = `tier=${c.proposed.tier} intent=${c.proposed.intent} deps=${c.proposed.dependencies || '(empty)'}`; | ||
| lines.push(`| \`${c.name}\` | ${existing} | ${proposed} | ${c.reason} |`); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| lines.push('## Ambiguous (multiple plausible classifications)', ''); | ||
| if (ambiguous.length === 0) { | ||
| lines.push('_None._', ''); | ||
| } else { | ||
| lines.push('| Skill | Tier | Intent | Reason |'); | ||
| lines.push('|-------|------|--------|--------|'); | ||
| for (const a of ambiguous) { | ||
| lines.push(`| \`${a.name}\` | ${a.tier} | ${a.intent} | ${a.reason} |`); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| lines.push('## Heuristic Misses (no clear match)', ''); | ||
| if (misses.length === 0) { | ||
| lines.push('_None._', ''); | ||
| } else { | ||
| lines.push('| Skill | Best guess | Confidence | Recommendation |'); | ||
| lines.push('|-------|-----------|------------|----------------|'); | ||
| for (const ms of misses) { | ||
| lines.push(`| \`${ms.name}\` | tier=${ms.tier} intent=${ms.intent} | low | Manual review needed |`); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| // ============================================================================= | ||
| // MAIN | ||
| // ============================================================================= | ||
| function main() { | ||
| const args = process.argv.slice(2); | ||
| const dryRun = args.includes('--dry-run'); | ||
| const force = args.includes('--force'); | ||
| const projectRoot = findProjectRoot(); | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const borderlinePath = path.join( | ||
| projectRoot, | ||
| '_bmad-output', | ||
| 'planning-artifacts', | ||
| 'portability-borderline.md' | ||
| ); | ||
| console.log(`Reading manifest from ${path.relative(projectRoot, manifestPath)}`); | ||
| const { header, rows } = readManifest(manifestPath); | ||
| // Build the set of canonical skill names for dependency-extraction filtering. | ||
| const nameIdx = header.indexOf('name'); | ||
| const validSkillNames = new Set(rows.map((r) => r[nameIdx])); | ||
| const tierIdx = header.indexOf('tier'); | ||
| const intentIdx = header.indexOf('intent'); | ||
| const depsIdx = header.indexOf('dependencies'); | ||
| if (tierIdx < 0 || intentIdx < 0 || depsIdx < 0) { | ||
| console.error( | ||
| 'ERROR: skill-manifest.csv is missing tier/intent/dependencies columns. Run sp-1-1 first.' | ||
| ); | ||
| process.exit(1); | ||
| } | ||
| const conflicts = []; | ||
| const ambiguous = []; | ||
| const misses = []; | ||
| let writes = 0; | ||
| let skips = 0; | ||
| for (const row of rows) { | ||
| const proposal = classifyRow(row, header, projectRoot, validSkillNames); | ||
| const existing = { | ||
| tier: row[tierIdx] || '', | ||
| intent: row[intentIdx] || '', | ||
| dependencies: row[depsIdx] || '', | ||
| }; | ||
| const rowName = row[header.indexOf('name')]; | ||
| // P3 (sp-1-2 review): if heuristics couldn't determine intent, route to | ||
| // misses and preserve existing values (do not clobber with `meta-platform`). | ||
| if (!proposal.intentMatched) { | ||
| misses.push({ | ||
| name: rowName, | ||
| tier: proposal.tier, | ||
| intent: existing.intent || '(none)', | ||
| dependencies: proposal.dependencies, | ||
| }); | ||
| // Skip the write so the row keeps whatever value was there before. | ||
| // If the row was previously unclassified, it stays unclassified — | ||
| // visible in BORDERLINE.md and caught by the test that requires | ||
| // every row to have non-empty tier+intent. | ||
| continue; | ||
| } | ||
| // Conflict detection | ||
| const hasExistingClassification = | ||
| existing.tier !== '' && existing.intent !== ''; | ||
| const differs = | ||
| existing.tier !== proposal.tier || | ||
| existing.intent !== proposal.intent || | ||
| existing.dependencies !== proposal.dependencies; | ||
| if (hasExistingClassification && differs && !force) { | ||
| // Manual override protected | ||
| conflicts.push({ | ||
| name: rowName, | ||
| existing, | ||
| proposed: proposal, | ||
| reason: 'Existing values differ from heuristics', | ||
| }); | ||
| skips++; | ||
| continue; | ||
| } | ||
| // Apply | ||
| row[tierIdx] = proposal.tier; | ||
| row[intentIdx] = proposal.intent; | ||
| row[depsIdx] = proposal.dependencies; | ||
| if (differs) writes++; | ||
| if (force && hasExistingClassification && differs) { | ||
| conflicts.push({ | ||
| name: rowName, | ||
| existing, | ||
| proposed: proposal, | ||
| reason: 'FORCED — manual override clobbered', | ||
| }); | ||
| } | ||
| } | ||
| console.log(`Processed ${rows.length} rows: ${writes} updated, ${skips} preserved (manual overrides)`); | ||
| if (dryRun) { | ||
| console.log('\n--- DRY RUN — preview ---'); | ||
| for (const row of rows) { | ||
| const name = row[header.indexOf('name')]; | ||
| console.log( | ||
| ` ${name.padEnd(50)} tier=${row[tierIdx].padEnd(11)} intent=${row[intentIdx].padEnd(22)} deps=${row[depsIdx] || '(empty)'}` | ||
| ); | ||
| } | ||
| console.log(`\nDry run complete. ${conflicts.length} conflicts, ${misses.length} heuristic misses.`); | ||
| return; | ||
| } | ||
| // Write manifest | ||
| writeManifest(manifestPath, header, rows); | ||
| console.log(`Wrote ${path.relative(projectRoot, manifestPath)}`); | ||
| // Write borderline file | ||
| const today = new Date().toISOString().slice(0, 10); | ||
| const md = renderBorderlineMd(today, conflicts, ambiguous, misses); | ||
| fs.mkdirSync(path.dirname(borderlinePath), { recursive: true }); | ||
| fs.writeFileSync(borderlinePath, md, 'utf8'); | ||
| console.log(`Wrote ${path.relative(projectRoot, borderlinePath)}`); | ||
| console.log( | ||
| `\nSummary: ${writes} updated, ${skips} preserved, ${conflicts.length} conflicts, ${misses.length} heuristic misses.` | ||
| ); | ||
| } | ||
| if (require.main === module) { | ||
| main(); | ||
| } | ||
| module.exports = { | ||
| classifyIntent, | ||
| classifyTier, | ||
| extractDependencies, | ||
| classifyRow, | ||
| readSkillContent, | ||
| // Constants exported for tests | ||
| META_PLATFORM_SKILLS, | ||
| VALID_TIERS, | ||
| VALID_INTENTS, | ||
| }; |
| #!/usr/bin/env node | ||
| /** | ||
| * convoke-export.js — Story sp-2-3 | ||
| * | ||
| * CLI entry point for the Tier 1 skill exporter. Wraps `exportSkill()` from | ||
| * sp-2-2's export-engine.js, writes per-skill `instructions.md` + `README.md` | ||
| * to disk, and reports success/failure with stable grep-friendly lines. | ||
| * | ||
| * Usage: | ||
| * convoke-export <skill-name> # single skill, default output | ||
| * convoke-export <skill-name> --output <dir> | ||
| * convoke-export --tier 1 # batch all standalone skills | ||
| * convoke-export --all # alias for --tier 1 (sp-2-3) | ||
| * convoke-export --tier 1 --dry-run # preview without writing | ||
| * convoke-export --help | ||
| * | ||
| * The CLI is read-only on the source tree — only the --output directory | ||
| * (or the default ./exported-skills/) is written. | ||
| * | ||
| * See _bmad-output/implementation-artifacts/sp-2-3-cli-entry-point.md for the | ||
| * full spec. | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { findProjectRoot } = require('../update/lib/utils'); | ||
| const { exportSkill, humanizeSkillName } = require('./export-engine'); | ||
| const { readManifest } = require('./manifest-csv'); | ||
| const { generateAdapters } = require('./generate-adapters'); | ||
| // ============================================================================= | ||
| // EXIT CODES | ||
| // ============================================================================= | ||
| const EXIT_SUCCESS = 0; | ||
| const EXIT_USAGE = 1; | ||
| const EXIT_NOT_FOUND = 2; | ||
| const EXIT_TIER_NOT_SUPPORTED = 3; | ||
| const EXIT_PARTIAL_FAILURE = 4; | ||
| // ============================================================================= | ||
| // ARG PARSER (hand-rolled, no deps — matches classify-skills.js style) | ||
| // ============================================================================= | ||
| /** | ||
| * Parse argv into a simple options object. | ||
| * Returns { positional: string[], output: string|null, tier: string|null, | ||
| * all: boolean, dryRun: boolean, help: boolean, unknown: string|null } | ||
| */ | ||
| function parseArgs(argv) { | ||
| const opts = { | ||
| positional: [], | ||
| output: null, | ||
| tier: null, | ||
| all: false, | ||
| dryRun: false, | ||
| help: false, | ||
| unknown: null, | ||
| }; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| const a = argv[i]; | ||
| if (a === '--help' || a === '-h') { | ||
| opts.help = true; | ||
| } else if (a === '--dry-run') { | ||
| opts.dryRun = true; | ||
| } else if (a === '--all') { | ||
| opts.all = true; | ||
| } else if (a === '--output') { | ||
| const next = argv[i + 1]; | ||
| if (!next || next.startsWith('--')) { | ||
| opts.unknown = '--output (missing value)'; | ||
| return opts; | ||
| } | ||
| opts.output = argv[++i]; | ||
| } else if (a.startsWith('--output=')) { | ||
| const val = a.slice('--output='.length); | ||
| if (!val) { opts.unknown = '--output= (empty value)'; return opts; } | ||
| opts.output = val; | ||
| } else if (a === '--tier') { | ||
| const next = argv[i + 1]; | ||
| if (!next || next.startsWith('--')) { | ||
| opts.unknown = '--tier (missing value)'; | ||
| return opts; | ||
| } | ||
| opts.tier = argv[++i]; | ||
| } else if (a.startsWith('--tier=')) { | ||
| const val = a.slice('--tier='.length); | ||
| if (!val) { opts.unknown = '--tier= (empty value)'; return opts; } | ||
| opts.tier = val; | ||
| } else if (a.startsWith('--')) { | ||
| opts.unknown = a; | ||
| return opts; | ||
| } else { | ||
| opts.positional.push(a); | ||
| } | ||
| } | ||
| return opts; | ||
| } | ||
| // ============================================================================= | ||
| // HELP TEXT (ASCII only — no emoji) | ||
| // ============================================================================= | ||
| function printHelp() { | ||
| const lines = [ | ||
| 'Usage: convoke-export <skill-name> [options]', | ||
| ' convoke-export --tier <N> [options]', | ||
| ' convoke-export --all [options]', | ||
| ' convoke-export --help', | ||
| '', | ||
| 'Description:', | ||
| ' Export a BMAD skill (Tier 1 standalone or Tier 2 light-deps) to a portable', | ||
| ' per-skill directory containing instructions.md and a README.md. Wraps the', | ||
| ' export engine. Read-only on the source tree.', | ||
| '', | ||
| 'Flags:', | ||
| ' <skill-name> Positional. Manifest skill name (e.g. bmad-brainstorming).', | ||
| ' --output <path> Output directory root. Defaults to ./exported-skills/', | ||
| ' relative to the project root. User-supplied paths are', | ||
| ' resolved against the current working directory.', | ||
| ' --tier <value> Batch-export by tier. Accepts: 1/standalone, 2/light-deps', | ||
| ' (both proceed); 3/pipeline (rejected).', | ||
| ' Any other value exits 1 (usage error).', | ||
| ' --all Export all exportable tiers (standalone + light-deps).', | ||
| ' --dry-run Run the engine in-memory; print would-be paths; write', | ||
| ' nothing. Combinable with all other flags.', | ||
| ' --help, -h Print this message and exit 0.', | ||
| '', | ||
| ' Conflicts:', | ||
| ' - <skill-name> with --tier or --all -> exit 1', | ||
| ' - --tier with --all -> exit 1', | ||
| ' - unknown flag -> exit 1', | ||
| '', | ||
| 'Exit codes:', | ||
| ' 0 Success (or empty batch, --help, --dry-run with no failures)', | ||
| ' 1 Usage error (unknown flag, conflicting flags, invalid --tier value)', | ||
| ' 2 Skill not found in manifest (single-skill mode)', | ||
| ' 3 Tier not supported (Tier 3 / pipeline requested)', | ||
| ' 4 Partial failure (batch mode with at least one failed skill)', | ||
| '', | ||
| 'Examples:', | ||
| ' Example: single skill, default output', | ||
| ' convoke-export bmad-brainstorming', | ||
| '', | ||
| ' Example: single skill, custom output', | ||
| ' convoke-export bmad-brainstorming --output ./out', | ||
| '', | ||
| ' Example: batch tier 1, dry-run preview', | ||
| ' convoke-export --tier 1 --dry-run', | ||
| '', | ||
| ' Example: batch all (standalone + light-deps)', | ||
| ' convoke-export --all', | ||
| '', | ||
| ]; | ||
| process.stdout.write(lines.join('\n')); | ||
| } | ||
| // ============================================================================= | ||
| // REPORTER | ||
| // ============================================================================= | ||
| function makeReporter() { | ||
| const results = { success: 0, failed: 0, skipped: 0, warnings: 0 }; | ||
| return { | ||
| success(skill, relPath, warnings) { | ||
| results.success++; | ||
| results.warnings += warnings; | ||
| const suffix = warnings > 0 ? ` (${warnings} warnings)` : ''; | ||
| process.stdout.write(`✅ ${skill} → ${relPath}${suffix}\n`); | ||
| }, | ||
| failure(skill, error) { | ||
| results.failed++; | ||
| const msg = (error && error.message ? error.message : String(error)).split('\n')[0]; | ||
| process.stderr.write(`❌ ${skill} — ${msg}\n`); | ||
| }, | ||
| skip(skill, reason) { | ||
| results.skipped++; | ||
| process.stdout.write(`⏭️ ${skill} — ${reason}\n`); | ||
| }, | ||
| summary(dryRun) { | ||
| const prefix = dryRun ? '[DRY RUN] ' : ''; | ||
| const total = results.success + results.failed + results.skipped; | ||
| process.stdout.write( | ||
| `${prefix}Exported ${total} skills (${results.success} success, ${results.failed} failed, ${results.skipped} skipped) — ${results.warnings} warnings total\n` | ||
| ); | ||
| }, | ||
| counts() { | ||
| return results; | ||
| }, | ||
| }; | ||
| } | ||
| // ============================================================================= | ||
| // README STUB GENERATION (Task 4) | ||
| // ============================================================================= | ||
| /** | ||
| * Read the readme template once. Cached after first call. | ||
| */ | ||
| let _templateCache = null; | ||
| function loadReadmeTemplate(projectRoot) { | ||
| if (_templateCache !== null) return _templateCache; | ||
| const tplPath = path.join( | ||
| projectRoot, | ||
| 'scripts', | ||
| 'portability', | ||
| 'templates', | ||
| 'readme-template.md' | ||
| ); | ||
| _templateCache = fs.readFileSync(tplPath, 'utf8'); | ||
| return _templateCache; | ||
| } | ||
| /** | ||
| * Build a per-skill README from manifest row + engine result. | ||
| * Reads the template, substitutes placeholders, strips HTML comments, | ||
| * cleans up leaked engine placeholders, and collapses whitespace. | ||
| * Throws if any multi-word placeholder remains after substitution. | ||
| */ | ||
| function buildReadme(skillRow, result, projectRoot) { | ||
| const template = loadReadmeTemplate(projectRoot); | ||
| const persona = result.persona || {}; | ||
| const displayName = humanizeSkillName(skillRow.name); | ||
| const nameWithIcon = persona.icon ? `${persona.name} ${persona.icon}` : persona.name || ''; | ||
| const commStyle = | ||
| (persona.communicationStyle && persona.communicationStyle.trim()) || | ||
| (persona.identity && persona.identity.trim()) || | ||
| 'See instructions.md for details.'; | ||
| // Strip the heading from "What you produce" — keep the body only. | ||
| const whatYouProduceBody = (result.sections.whatYouProduce || '') | ||
| .replace(/^##\s+What you produce\s*\n+/, '') | ||
| .trim(); | ||
| // Parse the "Use when:" bullet block from whenToUse. | ||
| const whenSection = result.sections.whenToUse || ''; | ||
| const bulletLines = whenSection | ||
| .split('\n') | ||
| .filter((l) => /^\s*-\s+/.test(l)) | ||
| .map((l) => l.replace(/^\s+/, '')); | ||
| const triggerList = | ||
| bulletLines.length > 0 | ||
| ? bulletLines.join('\n') | ||
| : '- See instructions.md for trigger conditions'; | ||
| // Substitute placeholders. Order matters where one token is a prefix of another. | ||
| let out = template; | ||
| out = out.replaceAll('<Skill display name>', displayName); | ||
| out = out.replaceAll('<persona name + icon>', nameWithIcon); | ||
| out = out.replaceAll('<persona name>', persona.name || ''); | ||
| out = out.replaceAll( | ||
| '<one-paragraph description of what the skill does and what value it delivers>', | ||
| skillRow.description || '' | ||
| ); | ||
| out = out.replaceAll('<persona communication style summary>', commStyle); | ||
| out = out.replaceAll('<output artifact description>', whatYouProduceBody); | ||
| out = out.replaceAll('<trigger-list>', triggerList); | ||
| out = out.replaceAll('<skill-name>', skillRow.name); | ||
| out = out.replaceAll('<tier>', skillRow.tier); | ||
| out = out.replaceAll('<standalone | light-deps | pipeline>', skillRow.tier); | ||
| // Sanity check: verify no multi-word <placeholder> tokens remain BEFORE | ||
| // stripping HTML comments (comments contain < chars that are fine). | ||
| const checkContent = out.replace(/<!--[\s\S]*?-->/g, ''); | ||
| const leftover = checkContent.match(/<[a-z][a-z\s-]{2,}[a-z]>/gi); | ||
| if (leftover && leftover.length > 0) { | ||
| throw new Error( | ||
| `README generation left unsubstituted placeholders: ${leftover.join(', ')}` | ||
| ); | ||
| } | ||
| // Strip HTML comments (developer docs in template, not user-facing) | ||
| out = out.replace(/<!--[\s\S]*?-->/g, ''); | ||
| // Clean up leaked engine placeholders from Phase 6 catch-all (all 6 mapped vars + catch-all) | ||
| out = out.replaceAll('[your output folder]', 'your-output-folder'); | ||
| out = out.replaceAll('[your name]', 'your-name'); | ||
| out = out.replaceAll('[your preferred language]', 'your-preferred-language'); | ||
| out = out.replaceAll('[your document language]', 'your-document-language'); | ||
| out = out.replaceAll('[your planning artifacts directory]', 'your-planning-artifacts'); | ||
| out = out.replaceAll('[your implementation artifacts directory]', 'your-implementation-artifacts'); | ||
| out = out.replaceAll('[your context]', 'your-project-context'); | ||
| // Collapse multiple blank lines and trim | ||
| out = out.replace(/\n{3,}/g, '\n\n').trim() + '\n'; | ||
| return out; | ||
| } | ||
| // ============================================================================= | ||
| // SINGLE-SKILL EXPORT | ||
| // ============================================================================= | ||
| /** | ||
| * Export one skill. Returns { ok: bool, exitCode: number, error?: Error }. | ||
| * Reporter is updated as a side effect. | ||
| */ | ||
| function runSingle(skillName, outputBase, dryRun, projectRoot, reporter) { | ||
| let result; | ||
| try { | ||
| result = exportSkill(skillName, projectRoot); | ||
| } catch (err) { | ||
| reporter.failure(skillName, err); | ||
| const msg = err.message || ''; | ||
| if (msg.includes('not in the manifest')) { | ||
| return { ok: false, exitCode: EXIT_NOT_FOUND, error: err }; | ||
| } | ||
| if (msg.includes('not standalone') || msg.includes('tier "')) { | ||
| return { ok: false, exitCode: EXIT_TIER_NOT_SUPPORTED, error: err }; | ||
| } | ||
| return { ok: false, exitCode: EXIT_PARTIAL_FAILURE, error: err }; | ||
| } | ||
| // Re-read the skill row for README stub generation | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const { header, rows } = readManifest(manifestPath); | ||
| const nameIdx = header.indexOf('name'); | ||
| const row = rows.find((r) => r[nameIdx] === skillName); | ||
| if (!row) { | ||
| const err = new Error(`Manifest row for "${skillName}" disappeared between engine call and README generation`); | ||
| reporter.failure(skillName, err); | ||
| return { ok: false, exitCode: EXIT_PARTIAL_FAILURE, error: err }; | ||
| } | ||
| const skillRow = {}; | ||
| for (let i = 0; i < header.length; i++) skillRow[header[i]] = row[i]; | ||
| let readme; | ||
| try { | ||
| readme = buildReadme(skillRow, result, projectRoot); | ||
| } catch (err) { | ||
| reporter.failure(skillName, err); | ||
| return { ok: false, exitCode: EXIT_PARTIAL_FAILURE, error: err }; | ||
| } | ||
| const skillDir = path.join(outputBase, skillName); | ||
| const instructionsPath = path.join(skillDir, 'instructions.md'); | ||
| const readmePath = path.join(skillDir, 'README.md'); | ||
| const relInstructions = path.relative(projectRoot, instructionsPath); | ||
| if (!dryRun) { | ||
| try { | ||
| fs.mkdirSync(skillDir, { recursive: true }); | ||
| fs.writeFileSync(instructionsPath, result.instructions); | ||
| fs.writeFileSync(readmePath, readme); | ||
| generateAdapters(skillName, skillRow, result.instructions, skillDir); | ||
| } catch (writeErr) { | ||
| reporter.failure(skillName, writeErr); | ||
| return { ok: false, exitCode: EXIT_PARTIAL_FAILURE, error: writeErr }; | ||
| } | ||
| } | ||
| reporter.success(skillName, relInstructions, result.warnings.length); | ||
| return { ok: true, exitCode: EXIT_SUCCESS }; | ||
| } | ||
| // ============================================================================= | ||
| // BATCH EXPORT | ||
| // ============================================================================= | ||
| /** | ||
| * Validate the --tier value. Returns { ok, normalizedTier, exitCode, message }. | ||
| */ | ||
| function validateTier(tierValue) { | ||
| if (tierValue === 'all') { | ||
| return { ok: true, normalizedTier: 'all' }; | ||
| } | ||
| if (tierValue === '1' || tierValue === 'standalone') { | ||
| return { ok: true, normalizedTier: 'standalone' }; | ||
| } | ||
| if (tierValue === '2' || tierValue === 'light-deps') { | ||
| return { ok: true, normalizedTier: 'light-deps' }; | ||
| } | ||
| if (tierValue === '3' || tierValue === 'pipeline') { | ||
| return { | ||
| ok: false, | ||
| exitCode: EXIT_TIER_NOT_SUPPORTED, | ||
| message: 'Tier 3 skills are not exported per the portability schema.', | ||
| }; | ||
| } | ||
| return { | ||
| ok: false, | ||
| exitCode: EXIT_USAGE, | ||
| message: `Invalid --tier value: '${tierValue}'. Valid values: 1, 2, 3, standalone, light-deps, pipeline.`, | ||
| }; | ||
| } | ||
| function runBatch(tierValue, outputBase, dryRun, projectRoot, reporter) { | ||
| const tier = validateTier(tierValue); | ||
| if (!tier.ok) { | ||
| process.stderr.write(`${tier.message}\n`); | ||
| return tier.exitCode; | ||
| } | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const { header, rows } = readManifest(manifestPath); | ||
| const nameIdx = header.indexOf('name'); | ||
| const tierIdx = header.indexOf('tier'); | ||
| // Manifest may contain the same skill name across multiple modules. | ||
| // Dedupe — each skill name is exported once. | ||
| // 'all' exports standalone + light-deps; specific tier exports that tier only | ||
| const tierFilter = tier.normalizedTier === 'all' | ||
| ? (t) => t === 'standalone' || t === 'light-deps' | ||
| : (t) => t === tier.normalizedTier; | ||
| const matchingSkills = [ | ||
| ...new Set(rows.filter((r) => tierFilter(r[tierIdx])).map((r) => r[nameIdx])), | ||
| ].sort(); | ||
| if (matchingSkills.length === 0) { | ||
| process.stdout.write('Nothing to export — manifest matches found 0 skills\n'); | ||
| return EXIT_SUCCESS; | ||
| } | ||
| for (const skillName of matchingSkills) { | ||
| runSingle(skillName, outputBase, dryRun, projectRoot, reporter); | ||
| } | ||
| const counts = reporter.counts(); | ||
| return counts.failed > 0 ? EXIT_PARTIAL_FAILURE : EXIT_SUCCESS; | ||
| } | ||
| // ============================================================================= | ||
| // MAIN | ||
| // ============================================================================= | ||
| function main() { | ||
| const argv = process.argv.slice(2); | ||
| // No args = print help and exit 0 | ||
| if (argv.length === 0) { | ||
| printHelp(); | ||
| return EXIT_SUCCESS; | ||
| } | ||
| const opts = parseArgs(argv); | ||
| if (opts.help) { | ||
| printHelp(); | ||
| return EXIT_SUCCESS; | ||
| } | ||
| if (opts.unknown) { | ||
| process.stderr.write(`Unknown flag: ${opts.unknown}. Run --help for usage.\n`); | ||
| return EXIT_USAGE; | ||
| } | ||
| // Conflict matrix | ||
| const hasPositional = opts.positional.length > 0; | ||
| const hasTier = opts.tier !== null; | ||
| const hasAll = opts.all; | ||
| if (hasPositional && (hasTier || hasAll)) { | ||
| process.stderr.write( | ||
| 'Conflict: positional skill name cannot be combined with --tier or --all. Run --help for usage.\n' | ||
| ); | ||
| return EXIT_USAGE; | ||
| } | ||
| if (hasTier && hasAll) { | ||
| process.stderr.write('Conflict: --tier and --all cannot be combined. Run --help for usage.\n'); | ||
| return EXIT_USAGE; | ||
| } | ||
| if (!hasPositional && !hasTier && !hasAll) { | ||
| process.stderr.write('No skill or batch flag provided. Run --help for usage.\n'); | ||
| return EXIT_USAGE; | ||
| } | ||
| if (opts.positional.length > 1) { | ||
| process.stderr.write( | ||
| `Conflict: only one positional skill name allowed (got ${opts.positional.length}). Run --help for usage.\n` | ||
| ); | ||
| return EXIT_USAGE; | ||
| } | ||
| const projectRoot = findProjectRoot(); | ||
| // Resolve output base | ||
| let outputBase; | ||
| if (opts.output) { | ||
| outputBase = path.isAbsolute(opts.output) | ||
| ? opts.output | ||
| : path.resolve(process.cwd(), opts.output); | ||
| } else { | ||
| outputBase = path.join(projectRoot, 'exported-skills'); | ||
| } | ||
| const reporter = makeReporter(); | ||
| let exitCode; | ||
| if (hasPositional) { | ||
| const result = runSingle(opts.positional[0], outputBase, opts.dryRun, projectRoot, reporter); | ||
| reporter.summary(opts.dryRun); | ||
| exitCode = result.exitCode; | ||
| } else { | ||
| // Batch: --tier or --all | ||
| const tierValue = hasAll ? 'all' : opts.tier; | ||
| exitCode = runBatch(tierValue, outputBase, opts.dryRun, projectRoot, reporter); | ||
| if (exitCode !== EXIT_TIER_NOT_SUPPORTED && exitCode !== EXIT_USAGE) { | ||
| reporter.summary(opts.dryRun); | ||
| } | ||
| } | ||
| return exitCode; | ||
| } | ||
| if (require.main === module) { | ||
| process.exit(main()); | ||
| } | ||
| module.exports = { | ||
| parseArgs, | ||
| validateTier, | ||
| buildReadme, | ||
| runSingle, | ||
| runBatch, | ||
| main, | ||
| EXIT_SUCCESS, | ||
| EXIT_USAGE, | ||
| EXIT_NOT_FOUND, | ||
| EXIT_TIER_NOT_SUPPORTED, | ||
| EXIT_PARTIAL_FAILURE, | ||
| }; |
| /** | ||
| * export-engine.js — Story sp-2-2 | ||
| * | ||
| * Pure-transform export engine for converting Tier 1 BMAD skills into the | ||
| * canonical LLM-agnostic format defined in | ||
| * scripts/portability/templates/canonical-format.md. | ||
| * | ||
| * The engine is read-only: it reads source files, returns transformed | ||
| * strings, and never writes anything. The CLI (sp-2-3) handles file output. | ||
| * | ||
| * Usage: | ||
| * const { exportSkill } = require('./scripts/portability/export-engine'); | ||
| * const result = exportSkill('bmad-brainstorming', findProjectRoot()); | ||
| * // result = { instructions, persona, sections, warnings } | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { readManifest } = require('./manifest-csv'); | ||
| // ============================================================================= | ||
| // CONSTANTS | ||
| // ============================================================================= | ||
| const META_SECTIONS_TO_STRIP = [ | ||
| 'MANDATORY EXECUTION RULES', | ||
| 'EXECUTION PROTOCOLS', | ||
| 'CONTEXT BOUNDARIES', | ||
| 'SUCCESS METRICS', | ||
| 'FAILURE MODES', | ||
| 'SYSTEM SUCCESS/FAILURE METRICS', | ||
| 'ROLE REINFORCEMENT', | ||
| 'STEP-SPECIFIC RULES', | ||
| 'NEXT STEPS', | ||
| 'NEXT STEP', | ||
| ]; | ||
| const ALLOWED_WARNING_TYPES = new Set([ | ||
| 'hook-script-stripped', | ||
| 'unresolved-template-path', | ||
| 'deep-conditional-skipped', | ||
| 'unstripped-xml-tag', | ||
| 'step-file-not-found', | ||
| ]); | ||
| // ============================================================================= | ||
| // HELPERS | ||
| // ============================================================================= | ||
| /** | ||
| * Recursively search a directory tree for a file by basename. | ||
| * Returns the first match found, or null. | ||
| */ | ||
| function findFileByName(dir, basename) { | ||
| try { | ||
| const entries = fs.readdirSync(dir, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| const full = path.join(dir, entry.name); | ||
| if (entry.isDirectory()) { | ||
| const found = findFileByName(full, basename); | ||
| if (found) return found; | ||
| } else if (entry.name === basename) { | ||
| return full; | ||
| } | ||
| } | ||
| } catch (_) { | ||
| // Permission error, symlink loop, etc. — skip silently | ||
| } | ||
| return null; | ||
| } | ||
| // ============================================================================= | ||
| // SOURCE LOADERS | ||
| // ============================================================================= | ||
| /** | ||
| * Read the skill manifest row for a given skill name. | ||
| * Throws if the skill is not in the manifest. | ||
| */ | ||
| function loadSkillRow(skillName, projectRoot) { | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const { header, rows } = readManifest(manifestPath); | ||
| const nameIdx = header.indexOf('name'); | ||
| const row = rows.find((r) => r[nameIdx] === skillName); | ||
| if (!row) { | ||
| throw new Error( | ||
| `Skill "${skillName}" is not in the manifest at ${path.relative(projectRoot, manifestPath)}.` | ||
| ); | ||
| } | ||
| // Return as a structured object | ||
| const result = {}; | ||
| for (let i = 0; i < header.length; i++) { | ||
| result[header[i]] = row[i]; | ||
| } | ||
| return result; | ||
| } | ||
| /** | ||
| * Load a skill's source files: SKILL.md + workflow.md + step files. | ||
| * Returns { skillContent, workflowContent, stepContents } | ||
| * where stepContents is { 'step-01-foo': '...', 'step-02a-bar': '...' } | ||
| */ | ||
| function loadSkillSource(skillRow, projectRoot, warnings) { | ||
| const skillPath = path.join(projectRoot, skillRow.path); | ||
| const skillContent = fs.existsSync(skillPath) ? fs.readFileSync(skillPath, 'utf8') : ''; | ||
| if (!skillContent) { | ||
| throw new Error(`SKILL.md not found at ${path.relative(projectRoot, skillPath)}`); | ||
| } | ||
| // Try to find workflow.md as a sibling of SKILL.md | ||
| const skillDir = path.dirname(skillPath); | ||
| const workflowPath = path.join(skillDir, 'workflow.md'); | ||
| const workflowContent = fs.existsSync(workflowPath) ? fs.readFileSync(workflowPath, 'utf8') : ''; | ||
| // Find step files: scan steps/ subdirectory if it exists | ||
| const stepContents = {}; | ||
| const stepsDir = path.join(skillDir, 'steps'); | ||
| if (fs.existsSync(stepsDir) && fs.statSync(stepsDir).isDirectory()) { | ||
| const stepFiles = fs.readdirSync(stepsDir).filter((f) => f.endsWith('.md')); | ||
| for (const stepFile of stepFiles) { | ||
| const stepName = stepFile.replace(/\.md$/, ''); | ||
| stepContents[stepName] = fs.readFileSync(path.join(stepsDir, stepFile), 'utf8'); | ||
| } | ||
| } | ||
| // Also scan for step subdirectories with patterns like steps-c, steps-t, etc. | ||
| const skillDirEntries = fs.readdirSync(skillDir, { withFileTypes: true }); | ||
| for (const entry of skillDirEntries) { | ||
| if (entry.isDirectory() && entry.name.startsWith('steps-')) { | ||
| const subStepsDir = path.join(skillDir, entry.name); | ||
| const subStepFiles = fs.readdirSync(subStepsDir).filter((f) => f.endsWith('.md')); | ||
| for (const stepFile of subStepFiles) { | ||
| const stepName = stepFile.replace(/\.md$/, ''); | ||
| stepContents[stepName] = fs.readFileSync(path.join(subStepsDir, stepFile), 'utf8'); | ||
| } | ||
| } | ||
| } | ||
| return { skillContent, workflowContent, stepContents, skillDir }; | ||
| } | ||
| // ============================================================================= | ||
| // PERSONA RESOLUTION | ||
| // ============================================================================= | ||
| /** | ||
| * Read the agent manifest and return all rows as objects. | ||
| */ | ||
| function loadAgentManifest(projectRoot) { | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'agent-manifest.csv'); | ||
| const { header, rows } = readManifest(manifestPath); | ||
| return rows.map((row) => { | ||
| const obj = {}; | ||
| for (let i = 0; i < header.length; i++) { | ||
| obj[header[i]] = row[i]; | ||
| } | ||
| return obj; | ||
| }); | ||
| } | ||
| /** | ||
| * Shared agent-manifest matching logic (Strategies 1 + 2 + 2b). | ||
| * No file I/O — operates purely on the passed-in agents array. | ||
| * Returns the matched agent row object, or null if no match. | ||
| */ | ||
| const CIS_SKILL_TO_AGENT = { | ||
| 'bmad-cis-storytelling': 'bmad-cis-agent-storyteller', | ||
| 'bmad-cis-innovation-strategy': 'bmad-cis-agent-innovation-strategist', | ||
| 'bmad-cis-problem-solving': 'bmad-cis-agent-creative-problem-solver', | ||
| }; | ||
| function findAgentMatch(skillName, agents) { | ||
| // Strategy 1: exact name match | ||
| let agent = agents.find((a) => a.name === skillName); | ||
| if (agent) return agent; | ||
| // Strategy 2: bmad-cis-agent-* pattern transformation | ||
| const base = skillName.startsWith('bmad-cis-') | ||
| ? skillName.replace(/^bmad-cis-/, 'bmad-cis-agent-') | ||
| : skillName.replace(/^bmad-/, 'bmad-cis-agent-'); | ||
| const candidates = [base, base + '-coach', base + '-specialist', base + '-expert']; | ||
| for (const candidate of candidates) { | ||
| agent = agents.find((a) => a.name === candidate); | ||
| if (agent) return agent; | ||
| } | ||
| // Strategy 2b: alias map for CIS stem mismatches | ||
| const aliasName = CIS_SKILL_TO_AGENT[skillName]; | ||
| if (aliasName) { | ||
| agent = agents.find((a) => a.name === aliasName); | ||
| if (agent) return agent; | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * Lightweight persona summary for catalog generation. | ||
| * Uses Strategies 1+2+2b only (no file reads). Falls back to humanized name + 🔧. | ||
| * @param {string} skillName | ||
| * @param {object[]} agents - array of agent-manifest row objects | ||
| * @returns {{ name: string, icon: string }} | ||
| */ | ||
| function resolvePersonaSummary(skillName, agents) { | ||
| const agent = findAgentMatch(skillName, agents); | ||
| if (agent) { | ||
| return { name: agent.displayName, icon: agent.icon || '' }; | ||
| } | ||
| return { name: humanizeSkillName(skillName), icon: '🔧' }; | ||
| } | ||
| /** | ||
| * Resolve a persona for the given skill via the 5-strategy fallback chain. | ||
| * Strategies 1-4 use agent-manifest or inline extraction. Strategy 5 | ||
| * synthesizes a minimal persona from workflow content (never throws). | ||
| */ | ||
| function loadPersona(skillName, skillContent, workflowContent, projectRoot) { | ||
| const agents = loadAgentManifest(projectRoot); | ||
| // Strategies 1 + 2 + 2b: manifest-based matching (shared with resolvePersonaSummary) | ||
| let agent = findAgentMatch(skillName, agents); | ||
| // Strategy 3: description fuzzy match (look for "talk to <Name>") | ||
| if (!agent) { | ||
| const allContent = skillContent + '\n' + workflowContent; | ||
| const nameMatch = allContent.match(/\b(?:talk to|requests? the|asks? to talk to|asks? for)\s+([A-Z][a-z]+)/); | ||
| if (nameMatch) { | ||
| const candidateName = nameMatch[1]; | ||
| agent = agents.find((a) => a.displayName === candidateName); | ||
| } | ||
| } | ||
| // Strategy 4: inline extraction from skill content | ||
| if (!agent) { | ||
| const inline = extractInlinePersona(skillContent + '\n' + workflowContent); | ||
| if (inline) return inline; | ||
| } | ||
| // Strategy 5: synthesize minimal persona from workflow/skill content (sp-2-4) | ||
| if (!agent) { | ||
| return synthesizePersonaFromWorkflow(skillName, skillContent, workflowContent, projectRoot); | ||
| } | ||
| // Convert agent row to persona object | ||
| return { | ||
| name: agent.displayName, | ||
| icon: agent.icon || '', | ||
| title: agent.title || '', | ||
| role: agent.role || '', | ||
| identity: agent.identity || '', | ||
| communicationStyle: agent.communicationStyle || '', | ||
| principles: agent.principles || '', | ||
| source: 'agent-manifest', | ||
| }; | ||
| } | ||
| /** | ||
| * Strategy 4: extract persona from inline markers in skill source. | ||
| * Looks for `# <Name>`, `## Identity`, `## Communication Style`, `## Principles` headings. | ||
| */ | ||
| function extractInlinePersona(content) { | ||
| // Try to find a top-level `# Name` heading near the start | ||
| const lines = content.split('\n'); | ||
| let name = null; | ||
| for (let i = 0; i < Math.min(50, lines.length); i++) { | ||
| const m = lines[i].match(/^#\s+([A-Z][a-zA-Z]+)\s*$/); | ||
| if (m) { | ||
| name = m[1]; | ||
| break; | ||
| } | ||
| } | ||
| if (!name) return null; | ||
| // Extract sections by heading | ||
| const identity = extractSectionByHeading(content, 'Identity'); | ||
| const commStyle = extractSectionByHeading(content, 'Communication Style'); | ||
| const principles = extractSectionByHeading(content, 'Principles'); | ||
| const overview = extractSectionByHeading(content, 'Overview'); | ||
| if (!identity && !commStyle && !principles) return null; | ||
| // Try to find an icon emoji near the name | ||
| const iconMatch = content.match(new RegExp(`#\\s+${name}\\s*([\\p{Emoji}])`, 'u')); | ||
| const icon = iconMatch ? iconMatch[1] : ''; | ||
| return { | ||
| name, | ||
| icon, | ||
| title: overview ? overview.split('\n')[0].slice(0, 80) : '', | ||
| role: '', | ||
| identity: identity || overview || '', | ||
| communicationStyle: commStyle || '', | ||
| principles: principles || '', | ||
| source: 'inline', | ||
| }; | ||
| } | ||
| /** | ||
| * Strategy 5: synthesize a minimal persona from workflow/skill content. | ||
| * Used for tool-like and wrapper skills that have no agent-manifest row | ||
| * and no inline persona block. Always returns a valid persona — never throws. | ||
| */ | ||
| function synthesizePersonaFromWorkflow(skillName, skillContent, workflowContent, projectRoot) { | ||
| const allContent = skillContent + '\n' + workflowContent; | ||
| // Name: humanized skill name (e.g., bmad-distillator → Distillator) | ||
| const name = humanizeSkillName(skillName); | ||
| // Role: extract from **Goal:** line, or Your Role: line, or fall back to skill name | ||
| let role = ''; | ||
| const goalMatch = allContent.match(/\*\*Goal:\*\*\s*(.+?)(?:\n|$)/); | ||
| if (goalMatch) { | ||
| role = goalMatch[1].trim(); | ||
| } else { | ||
| const roleMatch = allContent.match(/(?:\*\*)?Your Role:?\*?\*?\s*(.+?)(?:\n|$)/i); | ||
| if (roleMatch) role = roleMatch[1].trim(); | ||
| } | ||
| // Identity: ## Overview first paragraph, or manifest description | ||
| let identity = ''; | ||
| const overview = extractSectionByHeading(allContent, 'Overview'); | ||
| if (overview) { | ||
| // First non-empty paragraph | ||
| const para = overview.split(/\n\n/)[0]; | ||
| identity = para ? para.trim().slice(0, 200) : ''; | ||
| } | ||
| if (!identity) { | ||
| // Fall back to manifest description. Wrapped in try/catch to preserve the | ||
| // "never throws" contract — manifest was already read earlier in the pipeline | ||
| // but we re-read here for the description column. | ||
| try { | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const { header, rows } = readManifest(manifestPath); | ||
| const nameIdx = header.indexOf('name'); | ||
| const descIdx = header.indexOf('description'); | ||
| const row = rows.find((r) => r[nameIdx] === skillName); | ||
| if (row && descIdx >= 0) { | ||
| identity = (row[descIdx] || '').slice(0, 200); | ||
| } | ||
| } catch (_) { | ||
| // Manifest read failed — identity stays empty, which is acceptable | ||
| } | ||
| } | ||
| return { | ||
| name, | ||
| icon: '🔧', | ||
| title: role || name, | ||
| role: role || '', | ||
| identity: identity || role || '', | ||
| communicationStyle: '', | ||
| principles: '', | ||
| source: 'workflow-derived', | ||
| }; | ||
| } | ||
| /** | ||
| * Extract a markdown section by heading name (case-insensitive). | ||
| * Returns the content between `## <heading>` and the next `## ` heading. | ||
| */ | ||
| function extractSectionByHeading(content, headingName) { | ||
| const re = new RegExp(`^##\\s+${headingName}\\s*$([\\s\\S]*?)(?=^##\\s|(?![\\s\\S]))`, 'mi'); | ||
| const m = content.match(re); | ||
| if (!m) return null; | ||
| return m[1].trim(); | ||
| } | ||
| // ============================================================================= | ||
| // TRANSFORMATION RULES (Phases 1-7) | ||
| // ============================================================================= | ||
| /** | ||
| * Apply all transformation rules in order. Pure functional, no side effects | ||
| * except optional warning emission. | ||
| */ | ||
| function applyTransformations(text, warnings, options = {}) { | ||
| let result = text; | ||
| // Phase 1: Strip frontmatter blocks at the start of any block | ||
| result = result.replace(/^---\n[\s\S]*?\n---\n/gm, ''); | ||
| // Phase 2: Strip XML/MDX tags (keep content) | ||
| result = result.replace(/<\/?(workflow|step|action|check|critical|output|ask)(\s[^>]*)?>/g, ''); | ||
| // Phase 3: Strip framework calls (line-by-line) | ||
| const frameworkPatterns = [ | ||
| /bmad-init/i, | ||
| /bmad-help/i, | ||
| /Skill:\s*bmad-/i, | ||
| /\{project-root\}/, | ||
| /\.claude\/hooks/, | ||
| /bmad-speak/, | ||
| ]; | ||
| result = result | ||
| .split('\n') | ||
| .filter((line) => { | ||
| for (const pattern of frameworkPatterns) { | ||
| if (pattern.test(line)) { | ||
| // Track hook-script removals as warnings | ||
| if (warnings && /\.claude\/hooks|bmad-speak/.test(line)) { | ||
| warnings.push({ | ||
| type: 'hook-script-stripped', | ||
| message: `stripped line: ${line.trim().slice(0, 100)}`, | ||
| }); | ||
| } | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| }) | ||
| .join('\n'); | ||
| // Phase 3b: Strip _bmad/ paths (after framework calls — those references are already gone) | ||
| // Only strip lines whose primary content is a _bmad/ path (e.g., "Read _bmad/foo/bar.md"). | ||
| // Avoid stripping the line if _bmad/ appears only as a parenthetical or backtick reference. | ||
| result = result | ||
| .split('\n') | ||
| .filter((line) => { | ||
| // Strip lines that are predominantly a _bmad/ path | ||
| if (/^\s*[`*-]?\s*_bmad\//.test(line)) return false; | ||
| // Strip lines like "Load `{project-root}/_bmad/..." which the framework filter already caught | ||
| return true; | ||
| }) | ||
| .join('\n'); | ||
| // Phase 4: Strip micro-file directives | ||
| const microFileDirectives = [ | ||
| /Load step:/i, | ||
| /read fully and follow/i, | ||
| /Read fully and execute:/i, | ||
| /Load fully and follow:/i, | ||
| ]; | ||
| result = result | ||
| .split('\n') | ||
| .filter((line) => !microFileDirectives.some((p) => p.test(line))) | ||
| .join('\n'); | ||
| // Phase 5: Replace tool names | ||
| // Order matters — longer/more specific patterns first | ||
| result = result | ||
| .replace(/(?:the\s+)?Read tool/g, 'read the file at') | ||
| .replace(/(?:the\s+)?Edit tool/g, 'edit the file at') | ||
| .replace(/(?:the\s+)?Write tool/g, 'create a file at') | ||
| .replace(/(?:the\s+)?Bash tool/g, 'run the shell command') | ||
| .replace(/(?:the\s+)?Glob tool/g, 'find files matching') | ||
| .replace(/(?:the\s+)?Grep tool/g, 'search file contents for'); | ||
| // Skill tool: strip the entire line (Option A from Dev Notes) | ||
| result = result | ||
| .split('\n') | ||
| .filter((line) => !/Skill tool/.test(line)) | ||
| .join('\n'); | ||
| // Phase 6: Substitute config vars (skipped when processing template content — preserves {{var}} placeholders) | ||
| if (options.skipPhase6) { | ||
| // Phase 7: Collapse whitespace (still applies even when Phase 6 is skipped) | ||
| result = result.replace(/\n{3,}/g, '\n\n').trim(); | ||
| return result; | ||
| } | ||
| const configVarMap = { | ||
| user_name: '[your name]', | ||
| communication_language: '[your preferred language]', | ||
| document_output_language: '[your document language]', | ||
| output_folder: '[your output folder]', | ||
| planning_artifacts: '[your planning artifacts directory]', | ||
| implementation_artifacts: '[your implementation artifacts directory]', | ||
| }; | ||
| for (const [varName, replacement] of Object.entries(configVarMap)) { | ||
| const re = new RegExp(`\\{${varName}\\}`, 'g'); | ||
| result = result.replace(re, replacement); | ||
| } | ||
| // Also handle {{var}} double-brace forms | ||
| for (const [varName, replacement] of Object.entries(configVarMap)) { | ||
| const re = new RegExp(`\\{\\{${varName}\\}\\}`, 'g'); | ||
| result = result.replace(re, replacement); | ||
| } | ||
| // Strip any remaining {var} placeholders that weren't in the config map (avoid leakage). | ||
| // Emit a warning per unique unmapped var so typos in configVarMap don't go silent. | ||
| const unmappedSeen = new Set(); | ||
| result = result.replace(/\{\{?([\w_-]+)\}?\}/g, (_match, varName) => { | ||
| if (warnings && !unmappedSeen.has(varName)) { | ||
| unmappedSeen.add(varName); | ||
| warnings.push({ | ||
| type: 'unresolved-template-path', | ||
| message: `unmapped config var stripped via catch-all: {${varName}}`, | ||
| }); | ||
| } | ||
| return '[your context]'; | ||
| }); | ||
| // Phase 7: Collapse whitespace | ||
| result = result.replace(/\n{3,}/g, '\n\n').trim(); | ||
| return result; | ||
| } | ||
| // ============================================================================= | ||
| // SECTION EXTRACTORS | ||
| // ============================================================================= | ||
| /** | ||
| * Strip leading bmad- prefixes and convert kebab-case to title case. | ||
| */ | ||
| function humanizeSkillName(skillName) { | ||
| return skillName | ||
| .replace(/^bmad-cis-agent-/, '') | ||
| .replace(/^bmad-agent-/, '') | ||
| .replace(/^bmad-cis-/, '') | ||
| .replace(/^bmad-/, '') | ||
| .split('-') | ||
| .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) | ||
| .join(' '); | ||
| } | ||
| function extractTitle(skillName, persona) { | ||
| const display = humanizeSkillName(skillName); | ||
| if (persona && persona.name) { | ||
| return `# ${display} with ${persona.name}`; | ||
| } | ||
| return `# ${display}`; | ||
| } | ||
| function extractPersona(persona) { | ||
| const lines = []; | ||
| const nameWithIcon = persona.icon ? `${persona.name} ${persona.icon}` : persona.name; | ||
| lines.push(`## You are ${nameWithIcon}`); | ||
| lines.push(''); | ||
| if (persona.role || persona.title) { | ||
| lines.push(`**Role:** ${persona.role || persona.title}`); | ||
| lines.push(''); | ||
| } | ||
| if (persona.identity) { | ||
| lines.push(`**Identity:** ${persona.identity}`); | ||
| lines.push(''); | ||
| } | ||
| if (persona.communicationStyle) { | ||
| lines.push(`**Communication style:** ${persona.communicationStyle}`); | ||
| lines.push(''); | ||
| } | ||
| if (persona.principles) { | ||
| lines.push(`**Principles:**`); | ||
| lines.push(''); | ||
| // Principles may be a single string with bullets, or just prose | ||
| const principlesText = persona.principles; | ||
| if (principlesText.includes('- ')) { | ||
| // Already has bullets — keep as-is, just normalize | ||
| const bulletLines = principlesText | ||
| .split(/\n+/) | ||
| .map((l) => l.trim()) | ||
| .filter((l) => l.length > 0) | ||
| .map((l) => (l.startsWith('-') ? l : `- ${l}`)); | ||
| lines.push(...bulletLines); | ||
| } else { | ||
| // Split on sentences (approximate) | ||
| const sentences = principlesText.split(/(?<=\.)\s+/).filter((s) => s.trim().length > 0); | ||
| for (const s of sentences) { | ||
| lines.push(`- ${s.trim()}`); | ||
| } | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function extractWhenToUse(skillRow, workflowContent) { | ||
| const lines = []; | ||
| lines.push('## When to use this skill'); | ||
| lines.push(''); | ||
| // Description from skill manifest | ||
| const description = skillRow.description || ''; | ||
| // Strip the "Use when..." trailing clause if present, since we'll generate our own | ||
| const cleanDesc = description.replace(/\s*Use when[^.]*\.?\s*$/i, '').trim(); | ||
| lines.push(cleanDesc); | ||
| lines.push(''); | ||
| lines.push('**Use when:**'); | ||
| lines.push(''); | ||
| // Try to extract trigger conditions from the description's "Use when..." clause | ||
| const useWhenMatch = description.match(/Use when[^.]*\./i); | ||
| if (useWhenMatch) { | ||
| // Parse simple "X or Y" patterns | ||
| const trigger = useWhenMatch[0].replace(/^Use when\s+/i, '').replace(/\.$/, ''); | ||
| const parts = trigger.split(/\s+or\s+/i); | ||
| for (const part of parts) { | ||
| lines.push(`- ${part.trim()}`); | ||
| } | ||
| } else { | ||
| // Generate one fallback bullet | ||
| const humanName = humanizeSkillName(skillRow.name).toLowerCase(); | ||
| lines.push(`- The user explicitly requests ${humanName}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function extractInputs(workflowContent, stepContents) { | ||
| const lines = []; | ||
| lines.push('## Inputs you may need'); | ||
| lines.push(''); | ||
| const inputs = []; | ||
| // Look for `### Configuration Loading` block in workflow | ||
| const configBlock = workflowContent.match(/### Configuration Loading\s*([\s\S]*?)(?=^###|(?![\s\S]))/m); | ||
| if (configBlock) { | ||
| // Extract config var names mentioned | ||
| const varNames = [...configBlock[1].matchAll(/`?\{([\w_-]+)\}`?/g)].map((m) => m[1]); | ||
| const seen = new Set(); | ||
| for (const v of varNames) { | ||
| if (seen.has(v)) continue; | ||
| seen.add(v); | ||
| // Skip the universal config vars — they're substituted, not exposed as inputs | ||
| if ( | ||
| ['user_name', 'communication_language', 'document_output_language', 'output_folder', | ||
| 'planning_artifacts', 'implementation_artifacts', 'project_name', 'project_knowledge', | ||
| 'user_skill_level', 'date'].includes(v) | ||
| ) { | ||
| continue; | ||
| } | ||
| inputs.push(`- **${v.replace(/_/g, ' ')}.** Replace any placeholder for this with your project's actual value.`); | ||
| } | ||
| } | ||
| // Look for context_file references | ||
| if (/context_file/.test(workflowContent)) { | ||
| inputs.push('- **Optional context file.** A markdown file with project-specific guidance to inform the session.'); | ||
| } | ||
| if (inputs.length === 0) { | ||
| lines.push('(none required)'); | ||
| } else { | ||
| lines.push(...inputs); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function extractHowToProceed(workflowContent, stepContents, skillContent) { | ||
| const lines = []; | ||
| lines.push('## How to proceed'); | ||
| lines.push(''); | ||
| // Group step files by base step number to collapse branches | ||
| const stepNames = Object.keys(stepContents).sort(); | ||
| const groups = {}; | ||
| for (const stepName of stepNames) { | ||
| // Match patterns like "step-01-foo", "step-02a-bar", "step-t-01-baz" | ||
| const m = stepName.match(/step-(?:[a-z]-)?(\d+)/); | ||
| const num = m ? parseInt(m[1], 10) : 0; | ||
| if (!groups[num]) groups[num] = []; | ||
| groups[num].push(stepName); | ||
| } | ||
| const sortedNums = Object.keys(groups).map(Number).sort((a, b) => a - b); | ||
| if (sortedNums.length === 0) { | ||
| // No step files — derive from workflow content directly, then SKILL.md | ||
| // Search both workflow and skill content for procedural blocks | ||
| const sources = [workflowContent, skillContent || '']; | ||
| let extracted = null; | ||
| for (const source of sources) { | ||
| // Try several heading variants | ||
| const taskBlock = source.match(/##\s+(?:Your Task|Instructions|Workflow|Execution|How to proceed|On Activation)\s*([\s\S]*?)(?=^##\s|(?![\s\S]))/im); | ||
| if (taskBlock) { | ||
| extracted = taskBlock[1].trim(); | ||
| break; | ||
| } | ||
| } | ||
| if (extracted) { | ||
| lines.push(extracted); | ||
| } else { | ||
| lines.push('Follow the persona\'s established workflow as described in the persona section above. The user will guide the conversation.'); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| // Inline each step group as a numbered list item | ||
| let outputNum = 1; | ||
| for (const num of sortedNums) { | ||
| const groupSteps = groups[num]; | ||
| if (groupSteps.length === 1) { | ||
| // Single step — inline directly | ||
| const content = extractStepInstructionalContent(stepContents[groupSteps[0]]); | ||
| const title = extractStepTitle(stepContents[groupSteps[0]]) || `Step ${outputNum}`; | ||
| lines.push(`${outputNum}. **${title}**`); | ||
| lines.push(''); | ||
| const indented = content.split('\n').map((l) => l ? ` ${l}` : '').join('\n'); | ||
| lines.push(indented); | ||
| lines.push(''); | ||
| outputNum++; | ||
| } else { | ||
| // Branching — present as nested options | ||
| // Use the first step's title as the umbrella title | ||
| const umbrellaTitle = `Step ${outputNum} (multiple paths)`; | ||
| lines.push(`${outputNum}. **${umbrellaTitle}** — choose one of the following:`); | ||
| lines.push(''); | ||
| for (const stepName of groupSteps) { | ||
| const content = extractStepInstructionalContent(stepContents[stepName]); | ||
| const title = extractStepTitle(stepContents[stepName]) || stepName; | ||
| lines.push(` - **${title}**`); | ||
| const indented = content.split('\n').map((l) => l ? ` ${l}` : '').join('\n'); | ||
| lines.push(indented); | ||
| lines.push(''); | ||
| } | ||
| outputNum++; | ||
| } | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| function extractStepTitle(stepContent) { | ||
| // Look for the first `# Step X: Title` or `## Step X: Title` heading | ||
| const m = stepContent.match(/^#+\s+(?:Step\s+\d+[a-z]?:\s+)?(.+?)$/m); | ||
| if (m) return m[1].trim(); | ||
| return null; | ||
| } | ||
| function extractStepInstructionalContent(stepContent) { | ||
| // Strip meta sections by walking the content and skipping headings in the meta list | ||
| const lines = stepContent.split('\n'); | ||
| const result = []; | ||
| let skipping = false; | ||
| let inFirstHeading = true; | ||
| for (let i = 0; i < lines.length; i++) { | ||
| const line = lines[i]; | ||
| // Check for heading | ||
| const headingMatch = line.match(/^##\s+(.+?)\s*:?$/); | ||
| if (headingMatch) { | ||
| const headingText = headingMatch[1].toUpperCase().replace(/[^A-Z\s/]/g, '').trim(); | ||
| // Check if this matches a meta section | ||
| const isMeta = META_SECTIONS_TO_STRIP.some((meta) => headingText.includes(meta)); | ||
| if (isMeta) { | ||
| skipping = true; | ||
| continue; | ||
| } else { | ||
| skipping = false; | ||
| // Skip the very first heading (it's the step title, already captured separately) | ||
| if (inFirstHeading) { | ||
| inFirstHeading = false; | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
| // Skip H1 step titles too | ||
| if (/^#\s+Step\s+\d/i.test(line)) { | ||
| inFirstHeading = false; | ||
| continue; | ||
| } | ||
| if (!skipping) { | ||
| result.push(line); | ||
| } | ||
| } | ||
| // Collapse and trim | ||
| return result.join('\n').replace(/\n{3,}/g, '\n\n').trim(); | ||
| } | ||
| function extractWhatYouProduce(workflowContent, stepContents, skillRow) { | ||
| const lines = []; | ||
| lines.push('## What you produce'); | ||
| lines.push(''); | ||
| // Try several patterns | ||
| // 1. ## Output heading | ||
| const outputBlock = workflowContent.match(/^##\s+Output\s*([\s\S]*?)(?=^##\s|(?![\s\S]))/m); | ||
| if (outputBlock) { | ||
| lines.push(outputBlock[1].trim()); | ||
| return lines.join('\n'); | ||
| } | ||
| // 2. *_output_file or *_artifact path variable in ### Paths | ||
| const pathsBlock = workflowContent.match(/###\s+Paths\s*([\s\S]*?)(?=^###|^##|(?![\s\S]))/m); | ||
| if (pathsBlock) { | ||
| const outputFile = pathsBlock[1].match(/[`*]?(\w*_output_file|\w*_artifact)[`*]?\s*=\s*[`]?([^`\n]+)[`]?/); | ||
| if (outputFile) { | ||
| const humanName = humanizeSkillName(skillRow.name).toLowerCase(); | ||
| lines.push(`A markdown ${humanName} document at \`${outputFile[2].replace(/\{[\w_-]+\}/g, '[your output folder]')}\`. The document captures the session output and is intentionally raw — value comes from quantity and diversity, not pre-curation.`); | ||
| return lines.join('\n'); | ||
| } | ||
| } | ||
| // 3. **Goal:** line — extract the deliverable noun | ||
| const goalMatch = workflowContent.match(/\*\*Goal:\*\*\s+(.+?)(?:\n|$)/); | ||
| if (goalMatch) { | ||
| const humanName = humanizeSkillName(skillRow.name).toLowerCase(); | ||
| lines.push(`A markdown document capturing ${goalMatch[1].toLowerCase().replace(/^[a-z]/, (c) => c)}. Lives at \`[your output folder]/${humanName.replace(/\s+/g, '-')}/[date].md\`.`); | ||
| return lines.join('\n'); | ||
| } | ||
| // Fallback | ||
| const humanName = humanizeSkillName(skillRow.name).toLowerCase(); | ||
| lines.push(`A markdown document at \`[your output folder]/${humanName.replace(/\s+/g, '-')}/[date].md\`.`); | ||
| return lines.join('\n'); | ||
| } | ||
| function extractQualityChecks(workflowContent, stepContents) { | ||
| // Look for `## SUCCESS METRICS` blocks across step files | ||
| const checks = []; | ||
| for (const stepName of Object.keys(stepContents)) { | ||
| const content = stepContents[stepName]; | ||
| const successBlock = content.match(/##\s+(?:SUCCESS METRICS|SUCCESS CRITERIA|QUALITY CHECKS|CRITICAL RULES)\s*([\s\S]*?)(?=^##\s|(?![\s\S]))/mi); | ||
| if (successBlock) { | ||
| // Extract bullet items, only those that look like complete sentences (no truncation artifacts) | ||
| const bullets = successBlock[1] | ||
| .split('\n') | ||
| .map((l) => l.trim()) | ||
| .filter((l) => /^[-*✅]/.test(l)) | ||
| .map((l) => l.replace(/^[-*✅]\s*/, '').replace(/^✅\s*/, '').trim()) | ||
| // Filter out truncated entries (no trailing punctuation, ends mid-word) | ||
| .filter((l) => l.length > 10 && !l.endsWith(' ') && /[.a-zA-Z0-9]$/.test(l)); | ||
| checks.push(...bullets); | ||
| } | ||
| } | ||
| if (checks.length === 0) return null; // omit section entirely | ||
| // Dedupe (case-insensitive, normalize whitespace) | ||
| const seen = new Set(); | ||
| const unique = []; | ||
| for (const check of checks) { | ||
| const normalized = check.toLowerCase().replace(/\s+/g, ' ').trim(); | ||
| if (seen.has(normalized)) continue; | ||
| seen.add(normalized); | ||
| unique.push(check); | ||
| } | ||
| // Cap at 10 to keep the section digestible | ||
| const capped = unique.slice(0, 10); | ||
| const lines = []; | ||
| lines.push('## Quality checks'); | ||
| lines.push(''); | ||
| lines.push('Before declaring the session complete, verify:'); | ||
| lines.push(''); | ||
| for (const check of capped) { | ||
| lines.push(`- [ ] ${check}`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| // ============================================================================= | ||
| // TIER 2: DEPENDENCY HANDLING (sp-5-1) | ||
| // ============================================================================= | ||
| /** | ||
| * Categorize a skill's dependencies into templates, skill-refs, and sidecars. | ||
| * @param {string} depsString - semicolon-separated deps from manifest | ||
| * @param {string} projectRoot | ||
| * @param {Set<string>} manifestSkillNames - all skill names in manifest | ||
| * @param {string} skillDir - the skill's source directory (for relative path resolution) | ||
| * @returns {{ templates: Array, skillRefs: Array, sidecars: Array }} | ||
| */ | ||
| function categorizeDependencies(depsString, projectRoot, manifestSkillNames, skillDir) { | ||
| const templates = []; | ||
| const skillRefs = []; | ||
| const sidecars = []; | ||
| if (!depsString || !depsString.trim()) return { templates, skillRefs, sidecars }; | ||
| const deps = depsString.split(';').map((d) => d.trim()).filter(Boolean); | ||
| for (const dep of deps) { | ||
| // 1. Skill reference (matches a manifest name) | ||
| if (manifestSkillNames.has(dep)) { | ||
| skillRefs.push({ name: dep }); | ||
| continue; | ||
| } | ||
| // 2. Sidecar (path contains _memory or sidecar) | ||
| if (dep.includes('_memory') || dep.includes('sidecar')) { | ||
| sidecars.push({ name: path.basename(dep), path: dep }); | ||
| continue; | ||
| } | ||
| // 3. Template (path under templates/ directory, exists on disk) | ||
| // Try to resolve: direct resolution, project-root-relative, then subtree search by basename | ||
| let resolvedPath = null; | ||
| const candidates = [ | ||
| path.resolve(skillDir, dep), | ||
| path.join(projectRoot, dep), | ||
| ]; | ||
| for (const candidate of candidates) { | ||
| if (fs.existsSync(candidate)) { | ||
| resolvedPath = candidate; | ||
| break; | ||
| } | ||
| } | ||
| // Subtree search fallback: look for the basename under the skill dir tree | ||
| if (!resolvedPath && dep.endsWith('.md')) { | ||
| const basename = path.basename(dep); | ||
| const searchDirs = [skillDir, path.join(projectRoot, '_bmad')]; | ||
| for (const searchDir of searchDirs) { | ||
| if (!fs.existsSync(searchDir)) continue; | ||
| const found = findFileByName(searchDir, basename); | ||
| if (found) { resolvedPath = found; break; } | ||
| } | ||
| } | ||
| if (resolvedPath && dep.includes('templates/')) { | ||
| const content = fs.readFileSync(resolvedPath, 'utf8'); | ||
| const displayName = path.basename(dep, '.md') | ||
| .replace(/-/g, ' ') | ||
| .replace(/\b\w/g, (c) => c.toUpperCase()); | ||
| templates.push({ name: displayName, path: resolvedPath, content }); | ||
| continue; | ||
| } | ||
| // 4. Exists on disk but not under templates/ → sidecar (conservative) | ||
| if (resolvedPath) { | ||
| sidecars.push({ name: path.basename(dep), path: dep }); | ||
| continue; | ||
| } | ||
| // 5. Unknown → sidecar | ||
| sidecars.push({ name: path.basename(dep), path: dep }); | ||
| } | ||
| return { templates, skillRefs, sidecars }; | ||
| } | ||
| /** | ||
| * Build inlined template sections for Tier 2 skills. | ||
| * Each template gets its own ## heading with the content below. | ||
| * Applies transformations with skipPhase6 to preserve {{var}} placeholders. | ||
| */ | ||
| function buildTemplateSections(templates, warnings) { | ||
| const sections = []; | ||
| for (const tpl of templates) { | ||
| let content = tpl.content; | ||
| // Strip YAML frontmatter from template | ||
| content = content.replace(/^---\n[\s\S]*?\n---\n/, ''); | ||
| // Apply transformations Phases 1-5 + 7 only (skip Phase 6 to preserve {{var}}) | ||
| content = applyTransformations(content, warnings, { skipPhase6: true }); | ||
| sections.push(`## Template: ${tpl.name}`); | ||
| sections.push(''); | ||
| sections.push('> Replace template placeholders ({{...}}) with your project\'s actual values.'); | ||
| sections.push(''); | ||
| sections.push('Use this template as the starting structure for your output document.'); | ||
| sections.push(''); | ||
| sections.push(content); | ||
| } | ||
| return sections.join('\n'); | ||
| } | ||
| /** | ||
| * Build companion-skill and sidecar notes for the Inputs section. | ||
| */ | ||
| function buildDependencyNotes(skillRefs, sidecars, manifestSkillNames) { | ||
| const lines = []; | ||
| for (const ref of skillRefs) { | ||
| const qualifier = manifestSkillNames.has(ref.name) ? ' Available in the skills catalog.' : ''; | ||
| const displayName = humanizeSkillName(ref.name); | ||
| lines.push(`- **Companion skill: ${displayName}.** This skill works best when used together with the ${displayName} skill.${qualifier}`); | ||
| } | ||
| for (const sc of sidecars) { | ||
| lines.push(`- **Persistent data: ${sc.name}.** This skill maintains a data file for session history. Create an empty file at this path if starting fresh.`); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| // ============================================================================= | ||
| // MAIN: exportSkill | ||
| // ============================================================================= | ||
| /** | ||
| * Export a Tier 1 standalone skill into canonical instructions.md format. | ||
| * | ||
| * @param {string} skillName - The skill's canonical name (e.g., 'bmad-brainstorming') | ||
| * @param {string} projectRoot - Absolute path to the project root | ||
| * @param {object} [options] - Reserved for future use | ||
| * @returns {{ instructions: string, persona: object, sections: object, warnings: object[] }} | ||
| * | ||
| * Throws: | ||
| * - if the skill is not in the manifest | ||
| * - if the skill's tier is 'pipeline' (only standalone + light-deps are exportable) | ||
| * - if persona resolution fails (all 5 strategies) | ||
| */ | ||
| function exportSkill(skillName, projectRoot, options = {}) { | ||
| const warnings = []; | ||
| // 1. Load skill row + tier check (standalone + light-deps allowed; pipeline rejected) | ||
| const skillRow = loadSkillRow(skillName, projectRoot); | ||
| if (skillRow.tier === 'pipeline') { | ||
| throw new Error( | ||
| `${skillName} is tier "pipeline" — pipeline skills are not exported per the portability schema.` | ||
| ); | ||
| } | ||
| // 2. Load source files | ||
| const { skillContent, workflowContent, stepContents, skillDir } = loadSkillSource(skillRow, projectRoot, warnings); | ||
| // 3. Resolve persona (throws if all 5 strategies fail) | ||
| const persona = loadPersona(skillName, skillContent, workflowContent, projectRoot); | ||
| // 4. Categorize dependencies for Tier 2 (no-op for standalone — empty deps) | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const { header: mHeader, rows: mRows } = readManifest(manifestPath); | ||
| const mNameIdx = mHeader.indexOf('name'); | ||
| const manifestSkillNames = new Set(mRows.map((r) => r[mNameIdx])); | ||
| const deps = categorizeDependencies( | ||
| skillRow.dependencies || '', projectRoot, manifestSkillNames, skillDir || path.dirname(path.join(projectRoot, skillRow.path)) | ||
| ); | ||
| // 5. Run all section extractors | ||
| const sections = { | ||
| title: extractTitle(skillName, persona), | ||
| persona: extractPersona(persona), | ||
| whenToUse: extractWhenToUse(skillRow, workflowContent), | ||
| inputs: extractInputs(workflowContent, stepContents), | ||
| howToProceed: extractHowToProceed(workflowContent, stepContents, skillContent), | ||
| whatYouProduce: extractWhatYouProduce(workflowContent, stepContents, skillRow), | ||
| qualityChecks: extractQualityChecks(workflowContent, stepContents), | ||
| }; | ||
| // 5. Apply transformations to each section (post-extraction cleanup) | ||
| const transformedSections = {}; | ||
| for (const [key, value] of Object.entries(sections)) { | ||
| if (value === null) { | ||
| transformedSections[key] = null; | ||
| } else { | ||
| transformedSections[key] = applyTransformations(value, warnings); | ||
| } | ||
| } | ||
| // 5b. Replace template-path references in workflow text with "see Template section below" | ||
| if (deps.templates.length > 0 && transformedSections.howToProceed) { | ||
| for (const tpl of deps.templates) { | ||
| const basename = path.basename(tpl.path); | ||
| // Replace lines referencing the template file (load, read, use the template, etc.) | ||
| transformedSections.howToProceed = transformedSections.howToProceed | ||
| .split('\n') | ||
| .map((line) => { | ||
| if (line.includes(basename) || (line.includes('template') && line.includes('load'))) { | ||
| // Only replace if the line looks like a template-loading directive | ||
| if (/(?:load|read|use|initialize from|open)\b/i.test(line) && line.includes(basename)) { | ||
| return line.replace( | ||
| new RegExp(`[^\`]*${basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^\`]*`, 'i'), | ||
| `see the "Template: ${tpl.name}" section below` | ||
| ); | ||
| } | ||
| } | ||
| return line; | ||
| }) | ||
| .join('\n'); | ||
| } | ||
| } | ||
| // 6. Assemble the final instructions.md | ||
| const parts = [ | ||
| transformedSections.title, | ||
| '', | ||
| transformedSections.persona, | ||
| '', | ||
| transformedSections.whenToUse, | ||
| '', | ||
| transformedSections.inputs, | ||
| '', | ||
| transformedSections.howToProceed, | ||
| '', | ||
| transformedSections.whatYouProduce, | ||
| ]; | ||
| if (transformedSections.qualityChecks) { | ||
| parts.push(''); | ||
| parts.push(transformedSections.qualityChecks); | ||
| } | ||
| // 6b. Append dependency notes to inputs section (Tier 2 — no-op for standalone) | ||
| const depNotes = buildDependencyNotes(deps.skillRefs, deps.sidecars, manifestSkillNames); | ||
| if (depNotes) { | ||
| // Find the inputs part and append notes | ||
| const inputsIdx = parts.indexOf(transformedSections.inputs); | ||
| if (inputsIdx >= 0) { | ||
| parts[inputsIdx] = parts[inputsIdx] + '\n' + depNotes; | ||
| } | ||
| } | ||
| // 6c. Append inlined template sections (Tier 2 — no-op for standalone) | ||
| const templateContent = buildTemplateSections(deps.templates, warnings); | ||
| if (templateContent) { | ||
| parts.push(''); | ||
| parts.push(templateContent); | ||
| } | ||
| const instructions = parts.join('\n').replace(/\n{3,}/g, '\n\n').trim() + '\n'; | ||
| // 7. Final pass: catch any remaining unstripped XML tags as warnings | ||
| const remainingTags = instructions.match(/<\/?(workflow|step|action|check|critical|output|ask)\b/g); | ||
| if (remainingTags) { | ||
| warnings.push({ | ||
| type: 'unstripped-xml-tag', | ||
| message: `${remainingTags.length} unstripped XML tag(s) remain after pass: ${remainingTags.slice(0, 3).join(', ')}`, | ||
| }); | ||
| } | ||
| return { | ||
| instructions, | ||
| persona, | ||
| sections: transformedSections, | ||
| warnings, | ||
| }; | ||
| } | ||
| // ============================================================================= | ||
| // MODULE EXPORTS | ||
| // ============================================================================= | ||
| module.exports = { | ||
| exportSkill, | ||
| // Internal helpers exported for testing | ||
| loadSkillRow, | ||
| loadSkillSource, | ||
| loadPersona, | ||
| applyTransformations, | ||
| humanizeSkillName, | ||
| extractTitle, | ||
| extractPersona, | ||
| extractWhenToUse, | ||
| extractInputs, | ||
| extractHowToProceed, | ||
| extractWhatYouProduce, | ||
| extractQualityChecks, | ||
| ALLOWED_WARNING_TYPES, | ||
| // Catalog support (sp-3-1) | ||
| resolvePersonaSummary, | ||
| loadAgentManifest, | ||
| }; |
| /** | ||
| * generate-adapters.js — Story sp-5-2 | ||
| * | ||
| * Generates per-platform adapter files for exported skills. | ||
| * Each adapter wraps the canonical instructions.md with platform-specific | ||
| * metadata/formatting. Thin adapter principle: adapters add packaging, | ||
| * NOT content modifications. | ||
| * | ||
| * This is a module (no CLI entry point). Called from convoke-export.js's | ||
| * runSingle() after writing instructions.md and README.md. | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| /** | ||
| * Truncate description to first sentence (period + space) or 120 chars. | ||
| * Same logic as catalog-generator.js's truncateDescription. | ||
| */ | ||
| function truncateDescription(desc) { | ||
| if (!desc) return ''; | ||
| const periodIdx = desc.indexOf('. '); | ||
| if (periodIdx > 0 && periodIdx < 120) { | ||
| return desc.slice(0, periodIdx + 1); | ||
| } | ||
| if (desc.length <= 120) return desc; | ||
| return desc.slice(0, 120) + '...'; | ||
| } | ||
| /** | ||
| * Generate all platform adapters for a single exported skill. | ||
| * | ||
| * @param {string} skillName - manifest skill name (e.g., 'bmad-brainstorming') | ||
| * @param {object} skillRow - manifest row as keyed object (.name, .description, etc.) | ||
| * @param {string} instructionsContent - the canonical instructions.md content | ||
| * @param {string} skillOutputDir - path to the skill's export directory (e.g., <output>/bmad-brainstorming/) | ||
| */ | ||
| function generateAdapters(skillName, skillRow, instructionsContent, skillOutputDir) { | ||
| const adaptersDir = path.join(skillOutputDir, 'adapters'); | ||
| // Claude Code adapter | ||
| const claudeDir = path.join(adaptersDir, 'claude-code'); | ||
| fs.mkdirSync(claudeDir, { recursive: true }); | ||
| const description = truncateDescription(skillRow.description || ''); | ||
| // Escape single quotes in description for YAML safety | ||
| const safeDesc = description.replace(/'/g, "''"); | ||
| const claudeContent = [ | ||
| '---', | ||
| `name: ${skillName}`, | ||
| `description: '${safeDesc}'`, | ||
| '---', | ||
| '', | ||
| instructionsContent, | ||
| ].join('\n'); | ||
| fs.writeFileSync(path.join(claudeDir, 'SKILL.md'), claudeContent); | ||
| // GitHub Copilot adapter | ||
| const copilotDir = path.join(adaptersDir, 'copilot'); | ||
| fs.mkdirSync(copilotDir, { recursive: true }); | ||
| const displayName = (skillRow.name || skillName) | ||
| .replace(/^bmad-cis-agent-/, '') | ||
| .replace(/^bmad-agent-/, '') | ||
| .replace(/^bmad-cis-/, '') | ||
| .replace(/^bmad-/, '') | ||
| .split('-') | ||
| .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) | ||
| .join(' '); | ||
| const copilotContent = `<!-- Skill: ${displayName} — append to .github/copilot-instructions.md -->\n${instructionsContent}`; | ||
| fs.writeFileSync(path.join(copilotDir, 'copilot-instructions.md'), copilotContent); | ||
| // Cursor adapter | ||
| const cursorDir = path.join(adaptersDir, 'cursor'); | ||
| fs.mkdirSync(cursorDir, { recursive: true }); | ||
| fs.writeFileSync(path.join(cursorDir, `${skillName}.md`), instructionsContent); | ||
| } | ||
| module.exports = { generateAdapters }; |
| /** | ||
| * RFC-4180-aware CSV parser for skill-manifest.csv and friends. | ||
| * | ||
| * This is the manifest *parser* used by tests and the classification script. | ||
| * It is intentionally separate from `scripts/lib/csv-utils.js` (Team Factory's | ||
| * CSV writer utility) — different concerns, different consumers. | ||
| * | ||
| * Handles: | ||
| * - Quoted fields containing commas | ||
| * - Escaped quotes (`""` inside a quoted field → literal `"`) | ||
| * - Unquoted fields | ||
| * - Trailing CR (CRLF line endings) | ||
| * - UTF-8 BOM stripping | ||
| * - Whitespace-only line filtering | ||
| * | ||
| * Story: sp-1-1 (introduced parser inline in test), sp-1-2 (extracted to shared module). | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| /** | ||
| * Parse a single CSV row into an array of fields. | ||
| * | ||
| * Fields are trimmed of leading/trailing whitespace AFTER quote handling. | ||
| * This protects idempotency: hand-edited CSVs commonly accumulate stray | ||
| * spaces around values, and untrimmed comparisons would mark every such | ||
| * row as a manual-override conflict on every classifier run. | ||
| * | ||
| * @param {string} line | ||
| * @returns {string[]} | ||
| */ | ||
| function parseCsvRow(line) { | ||
| // Strip trailing CR for CRLF tolerance | ||
| if (line.endsWith('\r')) line = line.slice(0, -1); | ||
| const fields = []; | ||
| let current = ''; | ||
| let inQuotes = false; | ||
| for (let i = 0; i < line.length; i++) { | ||
| const ch = line[i]; | ||
| if (ch === '"') { | ||
| // RFC-4180: doubled quote inside a quoted field is a literal " | ||
| if (inQuotes && line[i + 1] === '"') { | ||
| current += '"'; | ||
| i++; | ||
| } else { | ||
| inQuotes = !inQuotes; | ||
| } | ||
| } else if (ch === ',' && !inQuotes) { | ||
| fields.push(current); | ||
| current = ''; | ||
| } else { | ||
| current += ch; | ||
| } | ||
| } | ||
| fields.push(current); | ||
| // P2 (sp-1-2 review): trim whitespace from every field. Fields that were | ||
| // originally quoted are unaffected by this since the quotes are already | ||
| // stripped during parse and any internal padding is preserved unless it | ||
| // was actually outside the quotes. | ||
| return fields.map((f) => f.trim()); | ||
| } | ||
| /** | ||
| * Count the number of CSV columns in a row (RFC-4180-aware). | ||
| * | ||
| * @param {string} line | ||
| * @returns {number} | ||
| */ | ||
| function countCsvColumns(line) { | ||
| return parseCsvRow(line).length; | ||
| } | ||
| /** | ||
| * Format a single field for CSV output. Quotes the field if it contains | ||
| * a comma, double-quote, or newline. Escapes embedded quotes by doubling. | ||
| * | ||
| * @param {string} field | ||
| * @returns {string} | ||
| */ | ||
| function formatCsvField(field) { | ||
| if (field == null) return ''; | ||
| const s = String(field); | ||
| if (s.includes(',') || s.includes('"') || s.includes('\n') || s.includes('\r')) { | ||
| return '"' + s.replace(/"/g, '""') + '"'; | ||
| } | ||
| return s; | ||
| } | ||
| /** | ||
| * Format an array of fields as a CSV row (no trailing newline). | ||
| * | ||
| * @param {string[]} fields | ||
| * @returns {string} | ||
| */ | ||
| function formatCsvRow(fields) { | ||
| return fields.map(formatCsvField).join(','); | ||
| } | ||
| /** | ||
| * Read a CSV file from disk and return its parsed contents. | ||
| * Strips UTF-8 BOM and ignores blank/whitespace-only lines. | ||
| * | ||
| * @param {string} filePath | ||
| * @returns {{ header: string[], rows: string[][], rawLines: string[] }} | ||
| * - `header`: array of column names | ||
| * - `rows`: array of data rows (each an array of fields) | ||
| * - `rawLines`: original (post-BOM-strip, post-filter) line strings — useful | ||
| * when callers need to preserve exact whitespace/quoting on rows they | ||
| * don't intend to modify | ||
| */ | ||
| function readManifest(filePath) { | ||
| let content = fs.readFileSync(filePath, 'utf8'); | ||
| // Strip UTF-8 BOM if present | ||
| if (content.charCodeAt(0) === 0xfeff) content = content.slice(1); | ||
| const rawLines = content.split('\n').filter((l) => /\S/.test(l)); | ||
| if (rawLines.length === 0) { | ||
| return { header: [], rows: [], rawLines: [] }; | ||
| } | ||
| const header = parseCsvRow(rawLines[0]); | ||
| const rows = rawLines.slice(1).map(parseCsvRow); | ||
| return { header, rows, rawLines }; | ||
| } | ||
| /** | ||
| * Write a manifest back to disk. Joins rows with `\n` and adds a trailing newline. | ||
| * | ||
| * @param {string} filePath | ||
| * @param {string[]} header | ||
| * @param {string[][]} rows | ||
| */ | ||
| function writeManifest(filePath, header, rows) { | ||
| const lines = [formatCsvRow(header), ...rows.map(formatCsvRow)]; | ||
| fs.writeFileSync(filePath, lines.join('\n') + '\n', 'utf8'); | ||
| } | ||
| module.exports = { | ||
| parseCsvRow, | ||
| countCsvColumns, | ||
| formatCsvField, | ||
| formatCsvRow, | ||
| readManifest, | ||
| writeManifest, | ||
| }; |
| #!/usr/bin/env node | ||
| /** | ||
| * seed-catalog-repo.js — Story sp-4-1 | ||
| * | ||
| * Generates the complete convoke-skills-catalog repo content into a staging | ||
| * directory. Orchestrates the export engine, catalog generator, and README | ||
| * builder into a single pipeline with built-in self-verification. | ||
| * | ||
| * Usage: | ||
| * node scripts/portability/seed-catalog-repo.js --output <path> | ||
| * node scripts/portability/seed-catalog-repo.js --help | ||
| * | ||
| * The script does NOT create a git repo or interact with GitHub. | ||
| * It only produces a directory tree. The user handles git init + push. | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { findProjectRoot } = require('../update/lib/utils'); | ||
| const { readManifest } = require('./manifest-csv'); | ||
| const { exportSkill, loadSkillRow } = require('./export-engine'); | ||
| const { generateCatalog } = require('./catalog-generator'); | ||
| const { buildReadme } = require('./convoke-export'); | ||
| const { generateAdapters } = require('./generate-adapters'); | ||
| // ============================================================================= | ||
| // CONSTANTS | ||
| // ============================================================================= | ||
| const { FORBIDDEN_STRINGS } = require('./test-constants'); | ||
| const MIT_LICENSE = `MIT License | ||
| Copyright (c) 2026 Convoke Contributors | ||
| Permission is hereby granted, free of charge, to any person obtaining a copy | ||
| of this software and associated documentation files (the "Software"), to deal | ||
| in the Software without restriction, including without limitation the rights | ||
| to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | ||
| copies of the Software, and to permit persons to whom the Software is | ||
| furnished to do so, subject to the following conditions: | ||
| The above copyright notice and this permission notice shall be included in all | ||
| copies or substantial portions of the Software. | ||
| THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | ||
| IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | ||
| FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | ||
| AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | ||
| LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | ||
| OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | ||
| SOFTWARE. | ||
| `; | ||
| const CONTRIBUTING_MD = `# Contributing | ||
| The skills in this repository are **auto-generated** from the main [Convoke Agents](https://github.com/amalik/convoke-agents) repository. They are regenerated on each release. | ||
| ## Important | ||
| - **Do not edit skill files directly.** Your changes will be overwritten on the next regeneration. | ||
| - To improve a skill's content, contribute to the source skill in the main Convoke repo. | ||
| - To request a new skill or report an issue, [open an issue](https://github.com/amalik/convoke-agents/issues) on the main repo. | ||
| ## How skills are generated | ||
| Each skill is exported from the Convoke framework using the \`convoke-export\` tool, which: | ||
| 1. Reads the skill's source files (agent definition, workflow, step files) | ||
| 2. Transforms them into an LLM-agnostic \`instructions.md\` format | ||
| 3. Generates a per-skill \`README.md\` with install instructions for Claude Code, GitHub Copilot, and Cursor | ||
| 4. Produces this catalog \`README.md\` organized by user intent | ||
| ## License | ||
| This repository is licensed under the MIT License. See [LICENSE](LICENSE) for details. | ||
| `; | ||
| // ============================================================================= | ||
| // GENERATION PIPELINE | ||
| // ============================================================================= | ||
| function generate(outputDir, projectRoot) { | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const { header, rows } = readManifest(manifestPath); | ||
| const nameIdx = header.indexOf('name'); | ||
| const tierIdx = header.indexOf('tier'); | ||
| // Get unique exportable skill names (standalone + light-deps) | ||
| const seen = new Set(); | ||
| const exportableNames = []; | ||
| for (const row of rows) { | ||
| const name = row[nameIdx]; | ||
| if (seen.has(name)) continue; | ||
| seen.add(name); | ||
| if (row[tierIdx] === 'standalone' || row[tierIdx] === 'light-deps') { | ||
| exportableNames.push(name); | ||
| } | ||
| } | ||
| exportableNames.sort(); | ||
| console.log(`Exporting ${exportableNames.length} skills (standalone + light-deps)...`); | ||
| // Create output directory | ||
| fs.mkdirSync(outputDir, { recursive: true }); | ||
| // Export each skill | ||
| let exportedCount = 0; | ||
| const failures = []; | ||
| for (const skillName of exportableNames) { | ||
| try { | ||
| const result = exportSkill(skillName, projectRoot); | ||
| const skillRow = loadSkillRow(skillName, projectRoot); | ||
| const readme = buildReadme(skillRow, result, projectRoot); | ||
| const skillDir = path.join(outputDir, skillName); | ||
| fs.mkdirSync(skillDir, { recursive: true }); | ||
| fs.writeFileSync(path.join(skillDir, 'instructions.md'), result.instructions); | ||
| fs.writeFileSync(path.join(skillDir, 'README.md'), readme); | ||
| generateAdapters(skillName, skillRow, result.instructions, skillDir); | ||
| exportedCount++; | ||
| } catch (err) { | ||
| failures.push({ skill: skillName, error: err.message.split('\n')[0] }); | ||
| } | ||
| } | ||
| if (failures.length > 0) { | ||
| console.error(`${failures.length} skill(s) failed to export:`); | ||
| for (const f of failures) { | ||
| console.error(` ${f.skill}: ${f.error}`); | ||
| } | ||
| throw new Error(`${failures.length} skill export(s) failed`); | ||
| } | ||
| console.log(`Exported ${exportedCount} skills successfully.`); | ||
| // Generate catalog README | ||
| console.log('Generating catalog README...'); | ||
| const catalogReadme = generateCatalog(projectRoot); | ||
| fs.writeFileSync(path.join(outputDir, 'README.md'), catalogReadme); | ||
| // Write LICENSE | ||
| fs.writeFileSync(path.join(outputDir, 'LICENSE'), MIT_LICENSE); | ||
| // Write CONTRIBUTING.md | ||
| fs.writeFileSync(path.join(outputDir, 'CONTRIBUTING.md'), CONTRIBUTING_MD); | ||
| console.log('Generation complete.'); | ||
| return { skillCount: exportedCount, expectedCount: exportableNames.length }; | ||
| } | ||
| // ============================================================================= | ||
| // SELF-VERIFICATION | ||
| // ============================================================================= | ||
| function verify(outputDir, expectedSkillCount) { | ||
| const failures = []; | ||
| // 1. Directory count | ||
| const entries = fs.readdirSync(outputDir, { withFileTypes: true }); | ||
| const skillDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name); | ||
| if (skillDirs.length !== expectedSkillCount) { | ||
| failures.push(`Directory count: expected ${expectedSkillCount}, got ${skillDirs.length}`); | ||
| } | ||
| // 2. Every skill dir has both files | ||
| for (const dir of skillDirs) { | ||
| const instrPath = path.join(outputDir, dir, 'instructions.md'); | ||
| const readmePath = path.join(outputDir, dir, 'README.md'); | ||
| if (!fs.existsSync(instrPath)) { | ||
| failures.push(`${dir}: missing instructions.md`); | ||
| } | ||
| if (!fs.existsSync(readmePath)) { | ||
| failures.push(`${dir}: missing README.md`); | ||
| } | ||
| } | ||
| // 3. Zero forbidden strings in all instructions.md | ||
| for (const dir of skillDirs) { | ||
| const instrPath = path.join(outputDir, dir, 'instructions.md'); | ||
| if (!fs.existsSync(instrPath)) continue; | ||
| const content = fs.readFileSync(instrPath, 'utf8'); | ||
| for (const forbidden of FORBIDDEN_STRINGS) { | ||
| if (content.includes(forbidden)) { | ||
| failures.push(`${dir}/instructions.md: contains "${forbidden}"`); | ||
| } | ||
| } | ||
| } | ||
| // 4. README validity (How to use, 3 platforms, under 80 lines) | ||
| for (const dir of skillDirs) { | ||
| const readmePath = path.join(outputDir, dir, 'README.md'); | ||
| if (!fs.existsSync(readmePath)) continue; | ||
| const content = fs.readFileSync(readmePath, 'utf8'); | ||
| if (!content.includes('How to use')) { | ||
| failures.push(`${dir}/README.md: missing "How to use"`); | ||
| } | ||
| if (!content.includes('Claude Code')) { | ||
| failures.push(`${dir}/README.md: missing Claude Code section`); | ||
| } | ||
| if (!content.includes('Copilot')) { | ||
| failures.push(`${dir}/README.md: missing Copilot section`); | ||
| } | ||
| if (!content.includes('Cursor')) { | ||
| failures.push(`${dir}/README.md: missing Cursor section`); | ||
| } | ||
| const lineCount = content.split('\n').length; | ||
| if (lineCount > 80) { | ||
| failures.push(`${dir}/README.md: ${lineCount} lines (exceeds 80)`); | ||
| } | ||
| } | ||
| // 4b. Adapters exist for each skill | ||
| for (const dir of skillDirs) { | ||
| const adaptersBase = path.join(outputDir, dir, 'adapters'); | ||
| if (!fs.existsSync(path.join(adaptersBase, 'claude-code', 'SKILL.md'))) { | ||
| failures.push(`${dir}: missing adapters/claude-code/SKILL.md`); | ||
| } | ||
| if (!fs.existsSync(path.join(adaptersBase, 'copilot', 'copilot-instructions.md'))) { | ||
| failures.push(`${dir}: missing adapters/copilot/copilot-instructions.md`); | ||
| } | ||
| if (!fs.existsSync(path.join(adaptersBase, 'cursor', `${dir}.md`))) { | ||
| failures.push(`${dir}: missing adapters/cursor/${dir}.md`); | ||
| } | ||
| } | ||
| // 5. Zero _bmad/ or .claude/hooks in ANY file | ||
| const allFiles = []; | ||
| function collectMdFiles(dir) { | ||
| for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { | ||
| const full = path.join(dir, entry.name); | ||
| if (entry.isDirectory()) { | ||
| collectMdFiles(full); | ||
| } else if (entry.name.endsWith('.md')) { | ||
| allFiles.push(full); | ||
| } | ||
| } | ||
| } | ||
| collectMdFiles(outputDir); | ||
| for (const filePath of allFiles) { | ||
| const content = fs.readFileSync(filePath, 'utf8'); | ||
| const rel = path.relative(outputDir, filePath); | ||
| if (content.includes('_bmad/')) { | ||
| failures.push(`${rel}: contains "_bmad/"`); | ||
| } | ||
| if (content.includes('.claude/hooks')) { | ||
| failures.push(`${rel}: contains ".claude/hooks"`); | ||
| } | ||
| } | ||
| // 6. Root README | ||
| const rootReadme = path.join(outputDir, 'README.md'); | ||
| if (!fs.existsSync(rootReadme)) { | ||
| failures.push('Root README.md missing'); | ||
| } else { | ||
| const content = fs.readFileSync(rootReadme, 'utf8'); | ||
| if (!content.includes('# Convoke Skills Catalog')) { | ||
| failures.push('Root README.md: missing "# Convoke Skills Catalog" heading'); | ||
| } | ||
| } | ||
| // 7. LICENSE | ||
| const licensePath = path.join(outputDir, 'LICENSE'); | ||
| if (!fs.existsSync(licensePath)) { | ||
| failures.push('LICENSE missing'); | ||
| } else { | ||
| const content = fs.readFileSync(licensePath, 'utf8'); | ||
| if (!content.includes('MIT')) { | ||
| failures.push('LICENSE: does not contain "MIT"'); | ||
| } | ||
| } | ||
| // 8. CONTRIBUTING.md | ||
| const contribPath = path.join(outputDir, 'CONTRIBUTING.md'); | ||
| if (!fs.existsSync(contribPath)) { | ||
| failures.push('CONTRIBUTING.md missing'); | ||
| } else { | ||
| const content = fs.readFileSync(contribPath, 'utf8'); | ||
| if (!content.includes('auto-generated')) { | ||
| failures.push('CONTRIBUTING.md: does not contain "auto-generated"'); | ||
| } | ||
| } | ||
| return failures; | ||
| } | ||
| // ============================================================================= | ||
| // CLI | ||
| // ============================================================================= | ||
| function printHelp() { | ||
| process.stdout.write( | ||
| [ | ||
| 'Usage: seed-catalog-repo --output <path>', | ||
| '', | ||
| 'Generate the complete convoke-skills-catalog repo content into a staging directory.', | ||
| 'Does NOT create a git repo or interact with GitHub.', | ||
| '', | ||
| 'Options:', | ||
| ' --output <path> Staging directory path (required)', | ||
| ' --help, -h Show this help message', | ||
| '', | ||
| 'Exit codes:', | ||
| ' 0 Success (all skills exported, verification passed)', | ||
| ' 1 Usage error', | ||
| ' 2 Generation failure', | ||
| ' 3 Verification failure', | ||
| '', | ||
| 'After the staging directory is generated, you can create the repo manually:', | ||
| '', | ||
| ' cd <staging-dir>', | ||
| ' git init', | ||
| ' git add -A', | ||
| ' git commit -m "Initial catalog seed"', | ||
| ' gh repo create convoke-skills-catalog --public --source=. --push', | ||
| '', | ||
| ].join('\n') | ||
| ); | ||
| } | ||
| function main() { | ||
| const argv = process.argv.slice(2); | ||
| if (argv.includes('--help') || argv.includes('-h') || argv.length === 0) { | ||
| printHelp(); | ||
| return argv.length === 0 ? 1 : 0; | ||
| } | ||
| let outputDir = null; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| if (argv[i] === '--output') { | ||
| const next = argv[i + 1]; | ||
| if (!next || next.startsWith('--')) { | ||
| process.stderr.write('Error: --output requires a path argument\n'); | ||
| return 1; | ||
| } | ||
| outputDir = argv[++i]; | ||
| } else if (argv[i].startsWith('--')) { | ||
| process.stderr.write(`Unknown flag: ${argv[i]}. Run --help for usage.\n`); | ||
| return 1; | ||
| } | ||
| } | ||
| if (!outputDir) { | ||
| process.stderr.write('Error: --output <path> is required. Run --help for usage.\n'); | ||
| return 1; | ||
| } | ||
| // Resolve output path | ||
| outputDir = path.isAbsolute(outputDir) ? outputDir : path.resolve(process.cwd(), outputDir); | ||
| let projectRoot; | ||
| try { | ||
| projectRoot = findProjectRoot(); | ||
| } catch (e) { | ||
| process.stderr.write(`Error: could not find project root — ${e.message}\n`); | ||
| return 2; | ||
| } | ||
| // Safety: refuse to write into a non-empty pre-existing directory. | ||
| // Prevents destructive cleanup from deleting user content on failure. | ||
| if (fs.existsSync(outputDir)) { | ||
| const existing = fs.readdirSync(outputDir); | ||
| if (existing.length > 0) { | ||
| process.stderr.write(`Error: output directory "${outputDir}" already exists and is non-empty.\nUse an empty or non-existent path.\n`); | ||
| return 1; | ||
| } | ||
| } | ||
| const dirCreatedByUs = !fs.existsSync(outputDir); | ||
| // Generate | ||
| let genResult; | ||
| try { | ||
| genResult = generate(outputDir, projectRoot); | ||
| } catch (e) { | ||
| process.stderr.write(`Generation failed: ${e.message}\n`); | ||
| // Only clean up if WE created the directory | ||
| if (dirCreatedByUs && fs.existsSync(outputDir)) { | ||
| fs.rmSync(outputDir, { recursive: true, force: true }); | ||
| } | ||
| return 2; | ||
| } | ||
| // Verify | ||
| let failures; | ||
| try { | ||
| console.log('Running verification...'); | ||
| failures = verify(outputDir, genResult.expectedCount); | ||
| } catch (e) { | ||
| process.stderr.write(`Verification crashed: ${e.message}\n`); | ||
| if (dirCreatedByUs && fs.existsSync(outputDir)) { | ||
| fs.rmSync(outputDir, { recursive: true, force: true }); | ||
| } | ||
| return 3; | ||
| } | ||
| if (failures.length > 0) { | ||
| process.stderr.write(`Verification failed with ${failures.length} issue(s):\n`); | ||
| for (const f of failures) { | ||
| process.stderr.write(` - ${f}\n`); | ||
| } | ||
| if (dirCreatedByUs) { | ||
| fs.rmSync(outputDir, { recursive: true, force: true }); | ||
| } | ||
| return 3; | ||
| } | ||
| console.log(`\nCatalog staging complete!`); | ||
| console.log(` Skills: ${genResult.skillCount}`); | ||
| console.log(` Directory: ${outputDir}`); | ||
| console.log(` Files: ${genResult.skillCount * 2 + 3} (${genResult.skillCount} x instructions.md + README.md + catalog + LICENSE + CONTRIBUTING.md)`); | ||
| console.log(` Verification: PASSED (zero violations)`); | ||
| console.log(`\nNext steps:`); | ||
| console.log(` cd ${outputDir}`); | ||
| console.log(` git init && git add -A && git commit -m "Initial catalog seed"`); | ||
| console.log(` gh repo create convoke-skills-catalog --public --source=. --push`); | ||
| return 0; | ||
| } | ||
| if (require.main === module) { | ||
| process.exit(main()); | ||
| } | ||
| module.exports = { generate, verify, main }; |
| # Brainstorming with Carson | ||
| ## You are Carson 🧠 | ||
| **Role:** Elite Brainstorming Specialist — Master Brainstorming Facilitator + Innovation Catalyst | ||
| **Identity:** You are an elite facilitator with 20+ years leading breakthrough sessions. Expert in creative techniques, group dynamics, and systematic innovation. You bring structured creativity techniques, facilitation expertise, and an understanding of how to guide users through effective ideation processes that generate innovative ideas and breakthrough solutions. | ||
| **Communication style:** Talk like an enthusiastic improv coach — high energy, build on ideas with "YES AND", celebrate wild thinking. Make the user feel safe to throw out half-formed ideas; the messier the better. | ||
| **Principles:** | ||
| - Psychological safety unlocks breakthroughs. Wild ideas today become innovations tomorrow. | ||
| - Humor and play are serious innovation tools. The session should feel slightly uncomfortable — that's where the novel ideas live. | ||
| - Keep the user in generative exploration mode as long as possible. Resist the urge to organize or conclude. When in doubt, ask another question, try another technique, or dig deeper into a promising thread. | ||
| - Combat semantic clustering bias: consciously shift creative domain every 10 ideas. If you've been focused on technical aspects, pivot to user experience, then to business viability, then to edge cases. | ||
| - The first 20 ideas are usually obvious. The magic happens in ideas 50-100. | ||
| ## When to use this skill | ||
| Use this skill to facilitate an interactive brainstorming session. Carson will guide the user through proven creative techniques to generate large quantities of ideas (target: 100+) before any organization or critique. The output is a session document capturing the topic, techniques used, ideas generated, and any patterns or themes the user wants to highlight. | ||
| **Use when:** | ||
| - The user explicitly says "help me brainstorm" or "I want to ideate on..." | ||
| - The user is stuck and needs to generate options before deciding | ||
| - The user has a problem statement but no candidate solutions yet | ||
| - The user wants to explore a topic creatively without committing to a direction | ||
| - The team is starting a new project and needs to surface possibilities | ||
| ## Inputs you may need | ||
| - **A topic or central question.** What is the user brainstorming about? (e.g., "how to onboard new consultants faster", "feature ideas for our sidekick tool") | ||
| - **Desired outcomes.** What kind of ideas are they hoping to generate? (Solutions? Features? Risks? Edge cases? All of the above?) | ||
| - **A place to write the session output.** Decide on an output folder before starting — replace `[your output folder]` with whatever the user prefers (e.g., `docs/brainstorms/`). | ||
| - **Optional context.** Any prior project notes, constraints, or relevant background the user wants you to consider. | ||
| ## How to proceed | ||
| 1. **Set up the session.** | ||
| - Greet the user warmly and explain that you'll guide them through proven creativity techniques. | ||
| - Ask two discovery questions: (1) What are we brainstorming about? (the central topic or challenge) and (2) What specific outcomes are they hoping for? (types of ideas, solutions, or insights) | ||
| - Wait for the user's responses, then summarize back what you heard: "Based on your responses, I understand we're focusing on **[topic]** with goals around **[objectives]**. Does this accurately capture what you want to achieve?" | ||
| - Confirm before proceeding. | ||
| 2. **Choose a technique selection approach.** Offer the user four ways to pick brainstorming techniques: | ||
| - **User-Selected** — browse the technique library and pick favorites | ||
| - **AI-Recommended** — Carson suggests techniques based on the topic and goals | ||
| - **Random Selection** — discover unexpected creative methods by chance | ||
| - **Progressive Flow** — start broad, then systematically narrow focus | ||
| - Wait for the user to choose 1-4. | ||
| 3. **Present and execute the chosen technique(s).** Brainstorming techniques include (but are not limited to): | ||
| - **What if scenarios** — pose provocative hypotheticals to break assumptions | ||
| - **Analogical thinking** — borrow patterns from unrelated domains (nature, games, other industries) | ||
| - **Reverse brainstorming** — ask "how could we make this worse?" then invert the answers | ||
| - **SCAMPER** — Substitute, Combine, Adapt, Modify, Put to other use, Eliminate, Reverse | ||
| - **Six thinking hats** — rotate through six perspective lenses (facts, emotions, caution, optimism, creativity, process) | ||
| - **Mind mapping** — branch from a central concept outward in all directions | ||
| - **Random word association** — pick a random word and force-fit it to the topic | ||
| - For each technique, explain it briefly (2-3 sentences), then guide the user through it interactively. Capture every idea — even the wild ones, especially the wild ones. | ||
| 4. **Generate aggressively, organize never.** While in generation mode: | ||
| - Aim for 100+ ideas before any organization or evaluation. | ||
| - Use "YES AND" to build on the user's ideas, never "no but". | ||
| - Every 10 ideas, **deliberately shift creative domain** to prevent semantic clustering. If you've been technical, pivot to user experience. If you've been on features, pivot to business model. If you've been on the happy path, pivot to edge cases or failure modes. | ||
| - When the user starts evaluating or critiquing mid-flow, gently redirect: "Great — let's hold that for later. Right now we're still generating. What else?" | ||
| - When ideas slow down, switch techniques rather than concluding. | ||
| 5. **Organize only when the user is ready.** When the user explicitly signals they want to wrap up generation: | ||
| - Ask if they want to organize ideas into categories, themes, or priorities. | ||
| - If yes, group ideas by similarity, surface patterns, and let the user name the categories themselves. | ||
| - If no, leave the raw idea list as the deliverable. | ||
| 6. **Capture the session.** Append everything to the session output document: | ||
| - Session topic and goals | ||
| - Technique(s) used | ||
| - All ideas generated (numbered, in order of appearance) | ||
| - Any organization or themes the user identified | ||
| - Optional: a "next steps" section if the user wants to flag specific ideas for follow-up | ||
| ## What you produce | ||
| A markdown brainstorming session document containing: | ||
| - **Session overview** — topic, goals, date, techniques used | ||
| - **Generated ideas** — the full list, numbered, in capture order. No filtering, no editing. | ||
| - **Organization** (optional) — categories, themes, or priorities the user identified | ||
| - **Next steps** (optional) — specific ideas the user flagged for follow-up | ||
| The document lives at `[your output folder]/brainstorming/brainstorming-session-[date].md`. It's intentionally raw and unfiltered — the value is in the quantity and the diversity, not in pre-curation. | ||
| ## Quality checks | ||
| Before declaring the session complete, verify: | ||
| - [ ] At least 50 ideas were generated (100+ is the target — fewer than 50 means the session ended too early) | ||
| - [ ] Creative domain shifted at least 3 times during the session (technical → UX → business → edge cases, etc.) | ||
| - [ ] No ideas were filtered, judged, or edited during generation mode | ||
| - [ ] The user explicitly signaled readiness to stop generating before any organization happened | ||
| - [ ] The session document captures every idea verbatim, in order | ||
| - [ ] If the user organized ideas, they (not Carson) chose the categories and named them |
| # Canonical Format Specification | ||
| Specification consumed by the export engine (sp-2-2). Defines what an exported skill looks like, how source files are transformed into canonical form, and where output files land. | ||
| This document is the **single source of truth** for the canonical `instructions.md` format. Any divergence between this spec and what the exporter produces is a bug in the exporter. | ||
| --- | ||
| ## Template | ||
| The canonical `instructions.md` has exactly seven top-level sections in this order. Sections 1-6 are required; section 7 is optional. | ||
| ```markdown | ||
| # <Skill display name> | ||
| <!-- e.g., "Brainstorming with Carson" — derived from skill name + persona --> | ||
| ## You are <persona name> | ||
| <!-- Persona block. Required fields: --> | ||
| <!-- - Name and icon (e.g., "Carson 🧠") --> | ||
| <!-- - Role/title (e.g., "Elite Brainstorming Specialist") --> | ||
| <!-- - Identity / background paragraph --> | ||
| <!-- - Communication style --> | ||
| <!-- - Core principles (bulleted) --> | ||
| **Role:** <role title> | ||
| **Identity:** <one-paragraph background> | ||
| **Communication style:** <how this persona talks> | ||
| **Principles:** | ||
| - <principle 1> | ||
| - <principle 2> | ||
| - <principle 3> | ||
| ## When to use this skill | ||
| <!-- One paragraph describing the skill's purpose, then a Use when: bullet list --> | ||
| <one-paragraph description> | ||
| **Use when:** | ||
| - <trigger condition 1> | ||
| - <trigger condition 2> | ||
| - <trigger condition 3> | ||
| ## Inputs you may need | ||
| <!-- What context, files, or values the user should have ready BEFORE invoking the skill. --> | ||
| <!-- If the skill needs nothing, write "(none required)". --> | ||
| - <input 1: description> | ||
| - <input 2: description> | ||
| ## How to proceed | ||
| <!-- The inlined workflow as a numbered list. Sub-steps are nested bullets. --> | ||
| <!-- This section REPLACES the original SKILL.md + workflow.md + steps/*.md. --> | ||
| <!-- All Claude tool references, framework calls, and config vars are stripped/replaced per the Transformation rules section below. --> | ||
| 1. <step 1 title> | ||
| - <sub-step 1.1> | ||
| - <sub-step 1.2> | ||
| 2. <step 2 title> | ||
| - <sub-step 2.1> | ||
| 3. <step 3 title> | ||
| ## What you produce | ||
| <!-- Description of the output artifact(s) the skill creates. --> | ||
| <!-- Be concrete about format, structure, and where it lives. --> | ||
| <output description> | ||
| ## Quality checks | ||
| <!-- OPTIONAL — only include if the source workflow has explicit quality gates. --> | ||
| <!-- Format as a checklist the LLM should run before declaring success. --> | ||
| - [ ] <check 1> | ||
| - [ ] <check 2> | ||
| ``` | ||
| ### Section purpose at a glance | ||
| | Section | Purpose | LLM consumption pattern | | ||
| |---|---|---| | ||
| | Title | Identifies the skill | First line — sets context | | ||
| | Persona | Tells the LLM "who to be" | Critical for tone + behavior consistency | | ||
| | When to use this skill | Trigger conditions | Helps the LLM decide whether the skill applies | | ||
| | Inputs you may need | Pre-flight checklist | Reduces "ask the user 5 questions in a row" patterns | | ||
| | How to proceed | The actual workflow | The meat — inlined steps, no external file refs | | ||
| | What you produce | Output description | Helps the LLM know when it's done | | ||
| | Quality checks | Self-validation | Optional — only when source has explicit gates | | ||
| ### Why no frontmatter | ||
| The canonical format is **platform-neutral**. Claude Code SKILL.md uses `---name: ... description: ---` frontmatter; Cursor uses different structure; Copilot uses none. Adapters (sp-5-2) wrap the canonical content in platform-specific frontmatter; the canonical content itself stays clean. | ||
| --- | ||
| ## Transformation rules | ||
| Every Claude-specific construct in the source skill files gets stripped or rewritten when producing the canonical `instructions.md`. The export engine (sp-2-2) implements these rules. | ||
| ### Tool name replacements | ||
| | Source pattern | Replacement | Notes | | ||
| |---|---|---| | ||
| | `Read tool` | `read the file at` | "Use the Read tool on X" → "read the file at X" | | ||
| | `Edit tool` | `edit the file at` | Preserve any path argument | | ||
| | `Write tool` | `create a file at` | "Write tool to X" → "create a file at X" | | ||
| | `Bash tool` | `run the shell command` | "Use the Bash tool to run X" → "run the shell command X" | | ||
| | `Glob tool` | `find files matching` | "Use Glob to find X" → "find files matching X" | | ||
| | `Grep tool` | `search file contents for` | "Use Grep on X" → "search file contents for X" | | ||
| | `Skill tool` | _(strip — see chained-skill rule)_ | Chained skills are not preserved in Tier 1 exports | | ||
| ### Framework call removals | ||
| | Source pattern | Action | Notes | | ||
| |---|---|---| | ||
| | `bmad-init` invocation | Remove the entire line/block | Universal — every skill calls it; not relevant once exported | | ||
| | References to `bmad-help` | Remove the reference | Framework-internal | | ||
| | `Skill: bmad-*` slash commands | Remove the line | Framework-internal chaining | | ||
| | Mentions of "config from {project-root}/_bmad/..." | Remove the mention | Framework path | | ||
| ### Config var substitutions | ||
| When the source contains `{var-name}` references to runtime config values, replace each with a square-bracket prompt the user fills in themselves: | ||
| | Source var | Replacement | | ||
| |---|---| | ||
| | `{user_name}` | `[your name]` | | ||
| | `{communication_language}` | `[your preferred language]` | | ||
| | `{document_output_language}` | `[your document language]` | | ||
| | `{output_folder}` | `[your output folder]` | | ||
| | `{planning_artifacts}` | `[your planning artifacts directory]` | | ||
| | `{implementation_artifacts}` | `[your implementation artifacts directory]` | | ||
| After applying these substitutions the canonical output should contain **zero curly-brace placeholders**. Any `{var-name}` left in the output is a bug. | ||
| ### Micro-file directive removals | ||
| These BMAD-framework directives are stripped entirely (replaced with inlined content): | ||
| | Source pattern | Action | | ||
| |---|---| | ||
| | `Load step:` | Strip — content is inlined at the matching position in "How to proceed" | | ||
| | `read fully and follow:` | Strip — same as above | | ||
| | `Read fully and execute:` | Strip — same as above | | ||
| | YAML frontmatter blocks (`---\n...\n---`) at the top of source files | Strip | | ||
| | `Load fully and follow:` | Strip — same as above | | ||
| ### Step file inlining rule | ||
| Workflows commonly reference step files like `./steps/step-01-foo.md` or `./steps/step-02b-recommend.md`. These are NOT external dependencies — they're part of the same skill package. The exporter must: | ||
| 1. Read each step file referenced from the workflow | ||
| 2. Extract the actual instructional content (skip MANDATORY EXECUTION RULES, EXECUTION PROTOCOLS, SUCCESS METRICS, and other meta sections) | ||
| 3. Inline that content at the corresponding numbered list item in "How to proceed" | ||
| 4. Apply all transformation rules (tool replacements, var substitutions) to the inlined content | ||
| When a workflow has branching step files (e.g., `step-02a`, `step-02b`, `step-02c` for different paths), the canonical "How to proceed" presents the branches as nested options under one numbered step rather than creating multiple top-level steps. | ||
| ### Workflow XML tag handling | ||
| BMAD workflows use HTML-like tags for structure. Strip the tags, keep the content as prose: | ||
| | Source pattern | Action | | ||
| |---|---| | ||
| | `<workflow>...</workflow>` | Strip tag, keep content | | ||
| | `<step n="X">...</step>` | Strip tag, content becomes a numbered list item | | ||
| | `<action>...</action>` | Strip tag, content becomes a sub-bullet | | ||
| | `<check if="...">...</check>` | Strip tag; convert to "If X, then Y" prose | | ||
| | `<critical>...</critical>` | Strip tag, content becomes prose (often **bold** for emphasis) | | ||
| | `<output>...</output>` | Strip tag — these are output templates the LLM is supposed to print; keep as quoted prose | | ||
| | `<ask>...</ask>` | Strip tag, content becomes "Ask the user: ..." prose | | ||
| ### Open questions / not yet specified | ||
| Some construct handling cannot be fully specified until the export engine is built and tested against real skills. sp-2-2 will resolve these empirically: | ||
| 1. **Conditional steps:** Workflows sometimes use `<check if="...">` with deeply nested branching. Should the canonical format use markdown conditionals (e.g., "If X, then ... else ...") or omit them entirely and present only the happy path? | ||
| 2. **Menu structures:** Some workflows present numbered menus to the user (`[A] Option, [B] Option, [C] Continue`). Should these be preserved verbatim or rewritten as "Ask the user to choose: ..." prose? | ||
| 3. **Hook/script references:** Some workflows invoke shell scripts via Bash with absolute or project-relative paths. The `Bash tool` → "run the shell command" replacement is in the table above, but what about paths like `.claude/hooks/bmad-speak.sh` that won't exist in a standalone project? | ||
| 4. **Multi-tier exports:** Tier 2 (light-deps) skills will need template inlining, which is sp-5-1's job. What does the canonical format say about a "Template" section for inlined templates? Should it be a new section (e.g., "## Templates") or appended under "Inputs you may need"? | ||
| 5. **Relative-template paths from sp-1-2 (Epic 1 retro action item A2):** sp-1-2's classifier produces relative-template paths like `../templates/prd-template.md` that resolve from a step file's directory, not the SKILL.md directory. The validator (sp-1-3) handles this with a subtree-search fallback. The exporter should reuse the same approach when inlining template content (sp-5-1 territory, but worth flagging here so sp-2-2 doesn't reinvent it). | ||
| 6. **`bmad-speak` and other hook scripts:** Some workflows reference `.claude/hooks/bmad-speak.sh` for character voice. These hooks won't exist in standalone exports — should the export drop the references entirely or leave a "(audio hook stripped)" comment? | ||
| These open questions are intentionally **not** locked in this story. sp-2-2 will encounter each one during implementation and document the resolution in its own dev notes. | ||
| --- | ||
| ## Output directory structure | ||
| The exporter produces the following per-skill layout: | ||
| ``` | ||
| <skill-name>/ | ||
| README.md ← what, why, who, how-to-install (catalog-facing) | ||
| instructions.md ← canonical LLM-agnostic content (this spec) | ||
| ``` | ||
| `README.md` is the catalog-facing entry point — the file a user reads first to decide whether to install the skill. Its template is in [readme-template.md](readme-template.md) (consumed by sp-3-2). | ||
| `instructions.md` is the LLM-consumed payload — the file an AI tool reads to actually perform the skill. Its template is the **Template** section above (consumed by sp-2-2). | ||
| ### Adapters are NOT in scope for sp-2-1 | ||
| Per-platform adapter wrappers (`adapters/claude-code/`, `adapters/copilot/`, `adapters/cursor/`) are explicitly **not** part of this story's spec. They belong to sp-5-2 (Platform Adapter Generation) and will wrap the canonical `instructions.md` in platform-specific frontmatter and conventions. | ||
| For sp-2-1 + sp-2-2 + sp-2-3 + sp-2-4, the canonical content alone is sufficient — Claude Code can consume `instructions.md` directly when copied into `.claude/skills/<skill-name>/`. |
| # <Skill display name> | ||
| <!-- Catalog-facing README. Consumed by sp-3-2 (Per-Skill README Generation). --> | ||
| <!-- The user reads this file FIRST to decide whether to install the skill. --> | ||
| <!-- For the LLM-consumed instructions, see instructions.md in the same directory. --> | ||
| > **Tier:** `<standalone | light-deps | pipeline>` | **Persona:** <persona name + icon> | ||
| <!-- Tier badge: --> | ||
| <!-- - standalone: works out of the box, no setup --> | ||
| <!-- - light-deps: needs templates/config inlined (Tier 2) --> | ||
| <!-- - pipeline: framework-internal or chained workflows (not portable; framework-internal) --> | ||
| ## What this skill does | ||
| <!-- One paragraph. Plain language. The reader is a teammate deciding whether to install. --> | ||
| <one-paragraph description of what the skill does and what value it delivers> | ||
| ## Who is <persona name>? | ||
| <!-- Communication style summary — helps the reader anticipate the persona's tone. --> | ||
| <!-- 2-3 sentences max. --> | ||
| <persona communication style summary> | ||
| ## When to use it | ||
| <!-- Bullet list of trigger conditions. Should match the "Use when:" section in instructions.md. --> | ||
| <trigger-list> | ||
| ## What it produces | ||
| <!-- Concrete description of the output artifact. Where it lives, what format. --> | ||
| <output artifact description> | ||
| ## How to use it | ||
| <!-- Per-platform install instructions using adapter files. --> | ||
| ### Claude Code | ||
| ```bash | ||
| cp adapters/claude-code/SKILL.md .claude/skills/<skill-name>/SKILL.md | ||
| ``` | ||
| Then invoke the skill in Claude Code by name or via slash command. | ||
| ### GitHub Copilot | ||
| ```bash | ||
| cat adapters/copilot/copilot-instructions.md >> .github/copilot-instructions.md | ||
| ``` | ||
| ### Cursor | ||
| ```bash | ||
| cp adapters/cursor/<skill-name>.md .cursor/rules/<skill-name>.md | ||
| ``` | ||
| ## Tier explanation | ||
| This skill is classified as **<tier>**. The three portability tiers are: | ||
| - **standalone** — works out of the box. Just copy and use. | ||
| - **light-deps** — includes inlined templates and config defaults. Just copy and use; no external setup. | ||
| - **pipeline** — framework-internal or part of a multi-step chain. Requires the full Convoke installation. NOT portable. | ||
| <!-- sp-3-1 will inject a link to the catalog repo's portability schema doc here when generating per-skill READMEs. The link is omitted from the template itself to avoid shipping a broken `<TODO>` token in every emitted README. --> | ||
| /** | ||
| * test-constants.js — Shared constants for portability tests and scripts. | ||
| * | ||
| * Single source of truth for the forbidden-string list used across the | ||
| * portability suite. Import this instead of duplicating the list. | ||
| * | ||
| * History: flagged in Epic 2 retro A4, Epic 3 retro A1, Epic 4 retro A1 | ||
| * as the #1 test-reliability debt item. Extracted here to close that debt. | ||
| */ | ||
| 'use strict'; | ||
| /** | ||
| * Strings that must NEVER appear in exported instructions.md files. | ||
| * These are Claude-specific tool names, framework calls, config paths, | ||
| * and micro-file directives that the export engine should have stripped. | ||
| */ | ||
| const FORBIDDEN_STRINGS = [ | ||
| // Claude tool names | ||
| 'Read tool', | ||
| 'Edit tool', | ||
| 'Write tool', | ||
| 'Bash tool', | ||
| 'Glob tool', | ||
| 'Grep tool', | ||
| 'Skill tool', | ||
| // Framework calls | ||
| 'bmad-init', | ||
| 'bmad-help', | ||
| 'bmad-speak', | ||
| // Framework paths | ||
| '_bmad/', | ||
| '.claude/hooks', | ||
| '{project-root}', | ||
| // Micro-file directives | ||
| 'Load step:', | ||
| 'read fully and follow', | ||
| 'Read fully and execute:', | ||
| 'Load fully and follow:', | ||
| ]; | ||
| module.exports = { FORBIDDEN_STRINGS }; |
| #!/usr/bin/env node | ||
| /** | ||
| * validate-classification.js — Story sp-1-3 | ||
| * | ||
| * Read-only validator for the classified skill manifest. Checks completeness, | ||
| * vocabulary correctness, and dependency integrity. Writes a markdown report. | ||
| * | ||
| * Usage: | ||
| * node scripts/portability/validate-classification.js | ||
| * | ||
| * Exit codes: | ||
| * 0 — PASS (no hard errors; warnings allowed) | ||
| * 1 — FAIL (one or more hard findings) | ||
| * | ||
| * Hard findings (exit 1): | ||
| * [MISSING] empty tier or intent | ||
| * [INVALID] out-of-vocabulary tier or intent | ||
| * [BROKEN-DEP] file-path dep doesn't resolve (or escapes project root) | ||
| * [BAD-CONFIG-DEP] malformed config: dep | ||
| * [ORPHAN-DEP] bare skill-name dep not in manifest | ||
| * | ||
| * Warnings (exit 0): | ||
| * [MISSING-PREREQS] pipeline skill (non-meta) with empty deps | ||
| * | ||
| * The script is read-only EXCEPT for the report file. It does not modify | ||
| * skill-manifest.csv, the schema doc, or any source files. | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const { findProjectRoot } = require('../update/lib/utils'); | ||
| const { readManifest } = require('./manifest-csv'); | ||
| // ============================================================================= | ||
| // CONSTANTS — must match scripts/portability/classify-skills.js | ||
| // ============================================================================= | ||
| const VALID_TIERS = ['standalone', 'light-deps', 'pipeline']; | ||
| const VALID_INTENTS = [ | ||
| 'think-through-problem', | ||
| 'define-what-to-build', | ||
| 'review-something', | ||
| 'write-documentation', | ||
| 'plan-your-work', | ||
| 'test-your-code', | ||
| 'discover-product-fit', | ||
| 'assess-readiness', | ||
| 'meta-platform', | ||
| ]; | ||
| const CONFIG_KEY_PATTERN = /^config:[a-z_][a-z0-9_]*$/; | ||
| // ============================================================================= | ||
| // VALIDATION CHECKS | ||
| // ============================================================================= | ||
| /** | ||
| * Check completeness + vocabulary for a single row. | ||
| * Returns an array of findings (may be empty). | ||
| */ | ||
| function checkRowVocabulary(row, header) { | ||
| const findings = []; | ||
| const name = row[header.indexOf('name')]; | ||
| const tier = row[header.indexOf('tier')]; | ||
| const intent = row[header.indexOf('intent')]; | ||
| if (!tier) { | ||
| findings.push({ | ||
| type: '[MISSING]', | ||
| skill: name, | ||
| detail: 'tier is empty', | ||
| recommendation: 'Run classify-skills.js to populate, or hand-edit if classifier produces a heuristic miss', | ||
| }); | ||
| } else if (!VALID_TIERS.includes(tier)) { | ||
| findings.push({ | ||
| type: '[INVALID]', | ||
| skill: name, | ||
| detail: `tier="${tier}" (not in canonical set: ${VALID_TIERS.join(', ')})`, | ||
| recommendation: 'Fix the tier value to one of the canonical values', | ||
| }); | ||
| } | ||
| if (!intent) { | ||
| findings.push({ | ||
| type: '[MISSING]', | ||
| skill: name, | ||
| detail: 'intent is empty', | ||
| recommendation: 'Run classify-skills.js to populate, or hand-edit if classifier produces a heuristic miss', | ||
| }); | ||
| } else if (!VALID_INTENTS.includes(intent)) { | ||
| findings.push({ | ||
| type: '[INVALID]', | ||
| skill: name, | ||
| detail: `intent="${intent}" (not in canonical set)`, | ||
| recommendation: 'Fix the intent value to one of the 9 canonical categories from portability-schema.md', | ||
| }); | ||
| } | ||
| return findings; | ||
| } | ||
| /** | ||
| * Check that a project-relative path stays inside projectRoot. | ||
| */ | ||
| function isInsideProjectRoot(absPath, projectRoot) { | ||
| const rootWithSep = projectRoot.endsWith(path.sep) ? projectRoot : projectRoot + path.sep; | ||
| return absPath === projectRoot || absPath.startsWith(rootWithSep); | ||
| } | ||
| /** | ||
| * Resolve a relative dep against a skill's source tree. | ||
| * | ||
| * Why this is non-trivial: sp-1-2's classifier flattens content from | ||
| * SKILL.md + workflow.md + step files into one blob, then extracts | ||
| * relative-template references. The references are correct relative to | ||
| * the file they appeared in (e.g., a step file in `steps-c/`), but the | ||
| * validator only knows the skill's SKILL.md path. Naïvely resolving | ||
| * `../templates/X` against `path.dirname(SKILL.md)` gives the wrong | ||
| * answer when the reference originated in a step subdirectory. | ||
| * | ||
| * Strategy: try the SKILL.md directory first (the simple case), then | ||
| * search the skill's subtree by basename. The first existing match wins. | ||
| * | ||
| * Returns one of: | ||
| * - {string} absolute resolved path (success) | ||
| * - {{ error: 'escapes project root' | 'not found', resolved: string }} (failure) | ||
| * | ||
| * Note: never returns null. The caller discriminates with | ||
| * `typeof result === 'string'`. | ||
| */ | ||
| function resolveRelativeDep(dep, skillDir, projectRoot) { | ||
| // Attempt 1: resolve against the skill's own directory (handles `./templates/X`) | ||
| const direct = path.resolve(skillDir, dep); | ||
| if (isInsideProjectRoot(direct, projectRoot) && fs.existsSync(direct)) { | ||
| return direct; | ||
| } | ||
| // Attempt 2: search the skill subtree for a file with the same basename. | ||
| // sp-1-2's classifier stripped the originating step-file directory context, | ||
| // so we walk the skill dir to recover it. | ||
| const basename = path.basename(dep); | ||
| try { | ||
| const found = findFileInSubtree(skillDir, basename, projectRoot); | ||
| if (found) return found; | ||
| } catch (e) { | ||
| // ignore stat errors during subtree walk | ||
| } | ||
| // Attempt 3 (escape check): if the direct resolution escaped the project | ||
| // root, surface that as the failure reason; otherwise fall through to "missing". | ||
| if (!isInsideProjectRoot(direct, projectRoot)) { | ||
| return { error: 'escapes project root', resolved: direct }; | ||
| } | ||
| return { error: 'not found', resolved: direct }; | ||
| } | ||
| /** | ||
| * Walk a directory subtree (bounded to projectRoot) looking for a file | ||
| * matching the target basename. Returns the first absolute match, or null. | ||
| * | ||
| * P3 (sp-1-3 review): symlinks are followed via `fs.statSync` (rather than | ||
| * Dirent.isFile/isDirectory which return false for symlinks). Cycles are | ||
| * prevented with a realpath visited-set. | ||
| * | ||
| * Stops descending at common skip directories to avoid runaway walks. | ||
| */ | ||
| function findFileInSubtree(dir, targetBasename, projectRoot) { | ||
| const SKIP = new Set(['node_modules', '.git', '_archive']); | ||
| const MAX_DEPTH = 6; | ||
| const visited = new Set(); | ||
| function walk(currentDir, depth) { | ||
| if (depth > MAX_DEPTH) return null; | ||
| if (!isInsideProjectRoot(currentDir, projectRoot)) return null; | ||
| // Cycle protection via realpath (follows symlinks) | ||
| let realDir; | ||
| try { | ||
| realDir = fs.realpathSync(currentDir); | ||
| } catch (e) { | ||
| return null; | ||
| } | ||
| if (visited.has(realDir)) return null; | ||
| visited.add(realDir); | ||
| let entries; | ||
| try { | ||
| entries = fs.readdirSync(currentDir); | ||
| } catch (e) { | ||
| return null; | ||
| } | ||
| // Files first (cheap match) — use statSync to follow symlinks | ||
| for (const name of entries) { | ||
| if (name !== targetBasename) continue; | ||
| const fullPath = path.join(currentDir, name); | ||
| try { | ||
| const stat = fs.statSync(fullPath); // follows symlinks | ||
| if (stat.isFile()) return fullPath; | ||
| } catch (e) { | ||
| // broken symlink or stat failure — skip | ||
| } | ||
| } | ||
| // Then recurse into subdirectories (also follows symlinks via statSync) | ||
| for (const name of entries) { | ||
| if (SKIP.has(name)) continue; | ||
| const fullPath = path.join(currentDir, name); | ||
| try { | ||
| const stat = fs.statSync(fullPath); | ||
| if (stat.isDirectory()) { | ||
| const found = walk(fullPath, depth + 1); | ||
| if (found) return found; | ||
| } | ||
| } catch (e) { | ||
| // broken symlink or stat failure — skip | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| return walk(dir, 0); | ||
| } | ||
| /** | ||
| * Check dependency entries for one row. Returns an array of findings. | ||
| * | ||
| * @param {string[]} row | ||
| * @param {string[]} header | ||
| * @param {string} projectRoot | ||
| * @param {Set<string>} validSkillNames | ||
| */ | ||
| function checkRowDependencies(row, header, projectRoot, validSkillNames) { | ||
| const findings = []; | ||
| const name = row[header.indexOf('name')]; | ||
| const skillPath = row[header.indexOf('path')]; | ||
| const depsStr = row[header.indexOf('dependencies')]; | ||
| if (!depsStr) return findings; | ||
| const deps = depsStr.split(';').filter((d) => d.length > 0); | ||
| const skillDir = path.dirname(path.join(projectRoot, skillPath)); | ||
| for (const dep of deps) { | ||
| // 1. Self-reference: skip silently | ||
| if (dep === name) continue; | ||
| // 2. Absolute project-relative file path | ||
| if (dep.startsWith('_bmad/')) { | ||
| const absPath = path.join(projectRoot, dep); | ||
| if (!fs.existsSync(absPath)) { | ||
| findings.push({ | ||
| type: '[BROKEN-DEP]', | ||
| skill: name, | ||
| detail: `dependency "${dep}" → ${path.relative(projectRoot, absPath)} (does not exist)`, | ||
| recommendation: 'Verify the dep path is correct, or remove the entry if the file was deleted', | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| // 3. Relative path. sp-1-2's classifier flattens content from SKILL.md + | ||
| // workflow + step files into one blob, so a `../templates/X` reference | ||
| // may have originated in a step subdirectory rather than the SKILL.md | ||
| // directory. resolveRelativeDep handles both: tries SKILL.md first, then | ||
| // searches the skill subtree by basename. | ||
| if (dep.startsWith('./') || dep.startsWith('../')) { | ||
| const result = resolveRelativeDep(dep, skillDir, projectRoot); | ||
| if (typeof result === 'string') { | ||
| // Resolved cleanly to an existing file — no finding | ||
| continue; | ||
| } | ||
| // Failure case: result is { error, resolved } | ||
| const escapesRoot = result && result.error === 'escapes project root'; | ||
| findings.push({ | ||
| type: '[BROKEN-DEP]', | ||
| skill: name, | ||
| detail: escapesRoot | ||
| ? `relative dep "${dep}" escapes project root (resolves to ${result.resolved})` | ||
| : `relative dep "${dep}" not found in skill subtree (searched ${path.relative(projectRoot, skillDir)} for ${path.basename(dep)})`, | ||
| recommendation: escapesRoot | ||
| ? 'Use a project-relative `_bmad/...` path instead' | ||
| : 'Verify the file exists somewhere in the skill directory tree, or remove the entry', | ||
| }); | ||
| continue; | ||
| } | ||
| // 4. Config key | ||
| if (dep.startsWith('config:')) { | ||
| if (!CONFIG_KEY_PATTERN.test(dep)) { | ||
| findings.push({ | ||
| type: '[BAD-CONFIG-DEP]', | ||
| skill: name, | ||
| detail: `malformed config dep "${dep}" (must match config:[a-z_][a-z0-9_]*)`, | ||
| recommendation: 'Fix the config key format — lowercase, alphanumeric + underscore only', | ||
| }); | ||
| } | ||
| continue; | ||
| } | ||
| // 5. Bare skill name → check membership in manifest | ||
| if (!validSkillNames.has(dep)) { | ||
| findings.push({ | ||
| type: '[ORPHAN-DEP]', | ||
| skill: name, | ||
| detail: `skill-name dep "${dep}" not found in manifest`, | ||
| recommendation: 'Dependency may have been removed; re-run classify-skills.js or remove the entry', | ||
| }); | ||
| } | ||
| } | ||
| return findings; | ||
| } | ||
| /** | ||
| * Check Tier 3 prerequisite documentation. Returns warning findings. | ||
| */ | ||
| function checkTier3Prereqs(row, header) { | ||
| const tier = row[header.indexOf('tier')]; | ||
| const intent = row[header.indexOf('intent')]; | ||
| const deps = row[header.indexOf('dependencies')]; | ||
| const name = row[header.indexOf('name')]; | ||
| if (tier !== 'pipeline') return []; | ||
| if (intent === 'meta-platform') return []; // framework-internal exemption | ||
| if (deps && deps.length > 0) return []; | ||
| return [ | ||
| { | ||
| type: '[MISSING-PREREQS]', | ||
| skill: name, | ||
| detail: 'pipeline skill has empty dependencies column', | ||
| recommendation: 'Known limitation of sp-1-2 classifier (does not extract artifact-consumption patterns) — not a fix in this story', | ||
| }, | ||
| ]; | ||
| } | ||
| // ============================================================================= | ||
| // REPORT GENERATION | ||
| // ============================================================================= | ||
| const FINDING_TYPES = [ | ||
| '[MISSING]', | ||
| '[INVALID]', | ||
| '[BROKEN-DEP]', | ||
| '[BAD-CONFIG-DEP]', | ||
| '[ORPHAN-DEP]', | ||
| '[MISSING-PREREQS]', | ||
| ]; | ||
| const HARD_FINDING_TYPES = new Set([ | ||
| '[MISSING]', | ||
| '[INVALID]', | ||
| '[BROKEN-DEP]', | ||
| '[BAD-CONFIG-DEP]', | ||
| '[ORPHAN-DEP]', | ||
| ]); | ||
| function escapeMarkdownTableCell(s) { | ||
| if (s == null) return ''; | ||
| return String(s).replace(/\|/g, '\\|').replace(/\n/g, ' '); | ||
| } | ||
| function renderReport(date, totalSkills, status, findings) { | ||
| const counts = Object.create(null); | ||
| for (const t of FINDING_TYPES) counts[t] = 0; | ||
| for (const f of findings) counts[f.type]++; | ||
| const lines = [ | ||
| '# Portability Classification — Validation Report', | ||
| '', | ||
| `Generated by \`scripts/portability/validate-classification.js\` on ${date}.`, | ||
| '', | ||
| `**Total skills checked:** ${totalSkills}`, | ||
| `**Status:** ${status}`, | ||
| '', | ||
| '## Summary', | ||
| '', | ||
| '| Finding type | Count |', | ||
| '|--------------|-------|', | ||
| `| [MISSING] | ${counts['[MISSING]']} |`, | ||
| `| [INVALID] | ${counts['[INVALID]']} |`, | ||
| `| [BROKEN-DEP] | ${counts['[BROKEN-DEP]']} |`, | ||
| `| [BAD-CONFIG-DEP] | ${counts['[BAD-CONFIG-DEP]']} |`, | ||
| `| [ORPHAN-DEP] | ${counts['[ORPHAN-DEP]']} |`, | ||
| `| [MISSING-PREREQS] | ${counts['[MISSING-PREREQS]']} (warning) |`, | ||
| '', | ||
| ]; | ||
| const sectionTitles = { | ||
| '[MISSING]': '[MISSING] — Unclassified rows', | ||
| '[INVALID]': '[INVALID] — Out-of-vocabulary values', | ||
| '[BROKEN-DEP]': "[BROKEN-DEP] — File-path dependencies that don't resolve", | ||
| '[BAD-CONFIG-DEP]': '[BAD-CONFIG-DEP] — Malformed config: dependencies', | ||
| '[ORPHAN-DEP]': "[ORPHAN-DEP] — Skill-name dependencies that don't exist in the manifest", | ||
| '[MISSING-PREREQS]': '[MISSING-PREREQS] — Pipeline skills with empty dependencies (warning)', | ||
| }; | ||
| for (const t of FINDING_TYPES) { | ||
| lines.push(`## ${sectionTitles[t]}`); | ||
| lines.push(''); | ||
| const rows = findings.filter((f) => f.type === t); | ||
| if (rows.length === 0) { | ||
| lines.push('_None._'); | ||
| lines.push(''); | ||
| continue; | ||
| } | ||
| lines.push('| Skill | Finding detail | Recommendation |'); | ||
| lines.push('|-------|----------------|----------------|'); | ||
| for (const r of rows) { | ||
| lines.push( | ||
| `| \`${escapeMarkdownTableCell(r.skill)}\` | ${escapeMarkdownTableCell(r.detail)} | ${escapeMarkdownTableCell(r.recommendation)} |` | ||
| ); | ||
| } | ||
| lines.push(''); | ||
| } | ||
| return lines.join('\n'); | ||
| } | ||
| // ============================================================================= | ||
| // MAIN | ||
| // ============================================================================= | ||
| function validate(projectRoot) { | ||
| const manifestPath = path.join(projectRoot, '_bmad', '_config', 'skill-manifest.csv'); | ||
| const { header, rows } = readManifest(manifestPath); | ||
| const tierIdx = header.indexOf('tier'); | ||
| const intentIdx = header.indexOf('intent'); | ||
| const depsIdx = header.indexOf('dependencies'); | ||
| if (tierIdx < 0 || intentIdx < 0 || depsIdx < 0) { | ||
| throw new Error( | ||
| 'skill-manifest.csv is missing tier/intent/dependencies columns. Run sp-1-1 first.' | ||
| ); | ||
| } | ||
| const nameIdx = header.indexOf('name'); | ||
| const validSkillNames = new Set(rows.map((r) => r[nameIdx])); | ||
| const findings = []; | ||
| for (const row of rows) { | ||
| // P2 (sp-1-3 review): a malformed row (missing path column, undefined | ||
| // fields) shouldn't crash the entire validator. Catch per-row errors | ||
| // and surface them as a [MISSING] finding so the rest of the manifest | ||
| // still gets validated. | ||
| try { | ||
| findings.push(...checkRowVocabulary(row, header)); | ||
| findings.push(...checkRowDependencies(row, header, projectRoot, validSkillNames)); | ||
| findings.push(...checkTier3Prereqs(row, header)); | ||
| } catch (e) { | ||
| const skillName = row[nameIdx] || '(unknown row)'; | ||
| findings.push({ | ||
| type: '[MISSING]', | ||
| skill: skillName, | ||
| detail: `row processing failed: ${e.message}`, | ||
| recommendation: 'Inspect this row in skill-manifest.csv for malformed fields (missing columns, undefined path, etc.)', | ||
| }); | ||
| } | ||
| } | ||
| return { totalSkills: rows.length, findings }; | ||
| } | ||
| function main() { | ||
| const projectRoot = findProjectRoot(); | ||
| let result; | ||
| try { | ||
| result = validate(projectRoot); | ||
| } catch (e) { | ||
| console.error(`ERROR: ${e.message}`); | ||
| process.exit(1); | ||
| } | ||
| const { totalSkills, findings } = result; | ||
| const errorCount = findings.filter((f) => HARD_FINDING_TYPES.has(f.type)).length; | ||
| const warningCount = findings.length - errorCount; | ||
| const status = errorCount === 0 ? 'PASS' : 'FAIL'; | ||
| const date = new Date().toISOString().slice(0, 10); | ||
| const report = renderReport(date, totalSkills, status, findings); | ||
| const reportPath = path.join( | ||
| projectRoot, | ||
| '_bmad-output', | ||
| 'planning-artifacts', | ||
| 'portability-validation-report.md' | ||
| ); | ||
| fs.mkdirSync(path.dirname(reportPath), { recursive: true }); | ||
| fs.writeFileSync(reportPath, report, 'utf8'); | ||
| // Single-line stdout summary | ||
| if (errorCount === 0) { | ||
| console.log(`PASS: ${totalSkills} skills validated, 0 errors, ${warningCount} warnings`); | ||
| } else { | ||
| const breakdown = {}; | ||
| for (const f of findings) { | ||
| if (HARD_FINDING_TYPES.has(f.type)) { | ||
| breakdown[f.type] = (breakdown[f.type] || 0) + 1; | ||
| } | ||
| } | ||
| const breakdownStr = Object.entries(breakdown) | ||
| .map(([k, v]) => `${v} ${k}`) | ||
| .join(', '); | ||
| console.log( | ||
| `FAIL: ${totalSkills} skills checked, ${errorCount} errors (${breakdownStr}), ${warningCount} warnings` | ||
| ); | ||
| } | ||
| console.log(`Report written to ${path.relative(projectRoot, reportPath)}`); | ||
| process.exit(errorCount === 0 ? 0 : 1); | ||
| } | ||
| if (require.main === module) { | ||
| main(); | ||
| } | ||
| module.exports = { | ||
| validate, | ||
| checkRowVocabulary, | ||
| checkRowDependencies, | ||
| checkTier3Prereqs, | ||
| renderReport, | ||
| VALID_TIERS, | ||
| VALID_INTENTS, | ||
| HARD_FINDING_TYPES, | ||
| }; |
| #!/usr/bin/env node | ||
| /** | ||
| * validate-exports.js — Story sp-4-2 | ||
| * | ||
| * Structural validation of a staging directory produced by seed-catalog-repo.js. | ||
| * Checks every exported skill for markdown validity, forbidden strings, persona | ||
| * sections, platform install instructions, and generates a VALIDATION-REPORT.md | ||
| * with automated results + manual test checklists. | ||
| * | ||
| * Usage: | ||
| * node scripts/portability/validate-exports.js --input <path> | ||
| * node scripts/portability/validate-exports.js --input <path> --report <path> | ||
| * node scripts/portability/validate-exports.js --help | ||
| */ | ||
| 'use strict'; | ||
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| // ============================================================================= | ||
| // CONSTANTS | ||
| // ============================================================================= | ||
| const { FORBIDDEN_STRINGS } = require('./test-constants'); | ||
| const XML_TAGS = [ | ||
| '<workflow>', '</workflow>', '<step ', '</step>', '<action>', '</action>', | ||
| '<check ', '</check>', '<critical>', '</critical>', '<output>', '</output>', | ||
| '<ask>', '</ask>', | ||
| ]; | ||
| const MANUAL_SMOKE_SKILLS = [ | ||
| { name: 'bmad-brainstorming', persona: 'Carson', role: 'brainstorming facilitator' }, | ||
| { name: 'bmad-agent-architect', persona: 'Winston', role: 'system architect' }, | ||
| { name: 'bmad-tea', persona: 'Murat', role: 'test architect' }, | ||
| ]; | ||
| // ============================================================================= | ||
| // VALIDATION CHECKS | ||
| // ============================================================================= | ||
| function validateInstructions(filePath, skillName) { | ||
| const issues = []; | ||
| if (!fs.existsSync(filePath)) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: 'file missing' }); | ||
| return issues; | ||
| } | ||
| const content = fs.readFileSync(filePath, 'utf8'); | ||
| // Starts with # heading | ||
| if (!content.startsWith('# ')) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: 'does not start with # heading' }); | ||
| } | ||
| // Persona section | ||
| if (!content.includes('## You are')) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: 'missing ## You are section' }); | ||
| } | ||
| // Workflow section | ||
| if (!content.includes('## How to proceed')) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: 'missing ## How to proceed section' }); | ||
| } | ||
| // Forbidden strings | ||
| for (const forbidden of FORBIDDEN_STRINGS) { | ||
| if (content.includes(forbidden)) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: `contains forbidden: "${forbidden}"` }); | ||
| } | ||
| } | ||
| // XML tags remaining | ||
| for (const tag of XML_TAGS) { | ||
| if (content.includes(tag)) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: `contains XML tag: ${tag}` }); | ||
| } | ||
| } | ||
| // Frontmatter blocks | ||
| // Check for YAML frontmatter at the START of the file (no m flag — ^ = string start) | ||
| if (/^---\n[\s\S]*?\n---\n/.test(content)) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: 'contains frontmatter block' }); | ||
| } | ||
| // Balanced code fences | ||
| const fenceCount = (content.match(/^```/gm) || []).length; | ||
| if (fenceCount % 2 !== 0) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: `unbalanced code fences (${fenceCount} found)` }); | ||
| } | ||
| // Broken markdown links (links to local files that aren't URLs or anchors) | ||
| const localLinks = [...content.matchAll(/\[([^\]]*)\]\((?!https?:\/\/|#|mailto:)([^)]+)\)/g)]; | ||
| for (const match of localLinks) { | ||
| const linkTarget = match[2]; | ||
| // Skip links containing template placeholders or non-path content | ||
| if (linkTarget.includes('[') || linkTarget.includes('{')) continue; | ||
| if (linkTarget.includes(' ')) continue; // URLs/paths don't have spaces; this is prose like "(none found)" | ||
| const resolved = path.resolve(path.dirname(filePath), linkTarget); | ||
| if (!fs.existsSync(resolved)) { | ||
| issues.push({ skill: skillName, file: 'instructions.md', issue: `broken link: [${match[1]}](${linkTarget})` }); | ||
| } | ||
| } | ||
| return issues; | ||
| } | ||
| function validateReadme(filePath, skillName) { | ||
| const issues = []; | ||
| if (!fs.existsSync(filePath)) { | ||
| issues.push({ skill: skillName, file: 'README.md', issue: 'file missing' }); | ||
| return issues; | ||
| } | ||
| const content = fs.readFileSync(filePath, 'utf8'); | ||
| // 3 platform sections | ||
| if (!content.includes('Claude Code')) { | ||
| issues.push({ skill: skillName, file: 'README.md', issue: 'missing Claude Code section' }); | ||
| } | ||
| if (!content.includes('Copilot')) { | ||
| issues.push({ skill: skillName, file: 'README.md', issue: 'missing Copilot section' }); | ||
| } | ||
| if (!content.includes('Cursor')) { | ||
| issues.push({ skill: skillName, file: 'README.md', issue: 'missing Cursor section' }); | ||
| } | ||
| // Line count | ||
| const lineCount = content.split('\n').length; | ||
| if (lineCount > 80) { | ||
| issues.push({ skill: skillName, file: 'README.md', issue: `${lineCount} lines (exceeds 80)` }); | ||
| } | ||
| // No HTML comments in output | ||
| if (content.includes('<!--')) { | ||
| issues.push({ skill: skillName, file: 'README.md', issue: 'contains HTML comments' }); | ||
| } | ||
| return issues; | ||
| } | ||
| function validateStagingDir(inputDir) { | ||
| const allIssues = []; | ||
| // Check root files | ||
| if (!fs.existsSync(path.join(inputDir, 'README.md'))) { | ||
| allIssues.push({ skill: 'ROOT', file: 'README.md', issue: 'missing catalog README' }); | ||
| } | ||
| if (!fs.existsSync(path.join(inputDir, 'LICENSE'))) { | ||
| allIssues.push({ skill: 'ROOT', file: 'LICENSE', issue: 'missing' }); | ||
| } | ||
| if (!fs.existsSync(path.join(inputDir, 'CONTRIBUTING.md'))) { | ||
| allIssues.push({ skill: 'ROOT', file: 'CONTRIBUTING.md', issue: 'missing' }); | ||
| } | ||
| // Validate each skill directory | ||
| const entries = fs.readdirSync(inputDir, { withFileTypes: true }); | ||
| const skillDirs = entries | ||
| .filter((e) => e.isDirectory() && !e.name.startsWith('.')) | ||
| .map((e) => e.name) | ||
| .sort(); | ||
| for (const skillName of skillDirs) { | ||
| const skillDir = path.join(inputDir, skillName); | ||
| allIssues.push( | ||
| ...validateInstructions(path.join(skillDir, 'instructions.md'), skillName), | ||
| ...validateReadme(path.join(skillDir, 'README.md'), skillName) | ||
| ); | ||
| } | ||
| return { issues: allIssues, skillCount: skillDirs.length, skillDirs }; | ||
| } | ||
| // ============================================================================= | ||
| // REPORT GENERATION | ||
| // ============================================================================= | ||
| function generateReport(inputDir, validationResult) { | ||
| const { issues, skillCount, skillDirs } = validationResult; | ||
| const date = new Date().toISOString().slice(0, 10); | ||
| const passCount = skillDirs.filter( | ||
| (d) => !issues.some((i) => i.skill === d) | ||
| ).length; | ||
| const failCount = skillCount - passCount; | ||
| const lines = []; | ||
| lines.push('# Export Validation Report'); | ||
| lines.push(''); | ||
| lines.push(`**Date:** ${date}`); | ||
| lines.push(`**Skills validated:** ${skillCount}`); | ||
| lines.push(`**Automated checks:** ${issues.length === 0 ? 'ALL PASSED' : `${issues.length} issue(s) found`}`); | ||
| lines.push(`**Pass/Fail:** ${passCount} passed, ${failCount} failed`); | ||
| lines.push(''); | ||
| lines.push('---'); | ||
| lines.push(''); | ||
| // Automated results | ||
| lines.push('## Automated Structural Checks'); | ||
| lines.push(''); | ||
| if (issues.length === 0) { | ||
| lines.push('All checks passed across all exported skills.'); | ||
| } else { | ||
| lines.push('| Skill | File | Issue |'); | ||
| lines.push('|---|---|---|'); | ||
| for (const issue of issues) { | ||
| lines.push(`| ${issue.skill} | ${issue.file} | ${issue.issue} |`); | ||
| } | ||
| } | ||
| lines.push(''); | ||
| lines.push('---'); | ||
| lines.push(''); | ||
| // Manual Claude Code smoke tests | ||
| lines.push('## Manual Smoke Tests — Claude Code'); | ||
| lines.push(''); | ||
| lines.push('Copy each skill into a clean project and invoke it. Verify persona behavior.'); | ||
| lines.push(''); | ||
| for (const skill of MANUAL_SMOKE_SKILLS) { | ||
| lines.push(`### ${skill.persona} (${skill.name})`); | ||
| lines.push(''); | ||
| lines.push(`- [ ] [PENDING] Copied \`${skill.name}/instructions.md\` to \`.claude/skills/${skill.name}/SKILL.md\``); | ||
| lines.push(`- [ ] [PENDING] Invoked skill — ${skill.persona} introduces self as ${skill.role}`); | ||
| lines.push(`- [ ] [PENDING] No references to \`_bmad/\`, \`bmad-init\`, or missing files`); | ||
| lines.push(`- [ ] [PENDING] Skill produces usable output`); | ||
| lines.push(''); | ||
| } | ||
| lines.push('---'); | ||
| lines.push(''); | ||
| // Manual Catalog walkthrough | ||
| lines.push('## Manual Catalog Walkthrough'); | ||
| lines.push(''); | ||
| lines.push('Have a teammate unfamiliar with BMAD read the catalog README.'); | ||
| lines.push(''); | ||
| lines.push('- [ ] [PENDING] Reader can identify what each intent category means'); | ||
| lines.push('- [ ] [PENDING] Reader finds a skill matching "I want to brainstorm" within 60 seconds'); | ||
| lines.push('- [ ] [PENDING] Tier badges are clear — "Ready to use" vs "Framework only" understood'); | ||
| lines.push('- [ ] [PENDING] "How to use a skill" section gives enough context to install'); | ||
| lines.push(''); | ||
| lines.push('---'); | ||
| lines.push(''); | ||
| lines.push(`*Generated by validate-exports.js on ${date}.*`); | ||
| lines.push(''); | ||
| return lines.join('\n'); | ||
| } | ||
| // ============================================================================= | ||
| // CLI | ||
| // ============================================================================= | ||
| function printHelp() { | ||
| process.stdout.write( | ||
| [ | ||
| 'Usage: validate-exports --input <path> [--report <path>]', | ||
| '', | ||
| 'Validate a staging directory produced by seed-catalog-repo.js.', | ||
| '', | ||
| 'Options:', | ||
| ' --input <path> Staging directory to validate (required)', | ||
| ' --report <path> Write VALIDATION-REPORT.md to this path (optional)', | ||
| ' --help, -h Show this help message', | ||
| '', | ||
| 'Exit codes:', | ||
| ' 0 All checks passed', | ||
| ' 1 Validation failures found', | ||
| ' 2 Usage error', | ||
| '', | ||
| ].join('\n') | ||
| ); | ||
| } | ||
| function main() { | ||
| const argv = process.argv.slice(2); | ||
| if (argv.includes('--help') || argv.includes('-h')) { | ||
| printHelp(); | ||
| return 0; | ||
| } | ||
| let inputDir = null; | ||
| let reportPath = null; | ||
| for (let i = 0; i < argv.length; i++) { | ||
| if (argv[i] === '--input') { | ||
| const next = argv[i + 1]; | ||
| if (!next || next.startsWith('--')) { | ||
| process.stderr.write('Error: --input requires a path argument\n'); | ||
| return 2; | ||
| } | ||
| inputDir = argv[++i]; | ||
| } else if (argv[i] === '--report') { | ||
| const next = argv[i + 1]; | ||
| if (!next || next.startsWith('--')) { | ||
| process.stderr.write('Error: --report requires a path argument\n'); | ||
| return 2; | ||
| } | ||
| reportPath = argv[++i]; | ||
| } else if (argv[i].startsWith('--')) { | ||
| process.stderr.write(`Unknown flag: ${argv[i]}. Run --help for usage.\n`); | ||
| return 2; | ||
| } | ||
| } | ||
| if (!inputDir) { | ||
| process.stderr.write('Error: --input <path> is required. Run --help for usage.\n'); | ||
| return 2; | ||
| } | ||
| if (!fs.existsSync(inputDir)) { | ||
| process.stderr.write(`Error: input directory "${inputDir}" does not exist\n`); | ||
| return 2; | ||
| } | ||
| if (!fs.statSync(inputDir).isDirectory()) { | ||
| process.stderr.write(`Error: "${inputDir}" is not a directory\n`); | ||
| return 2; | ||
| } | ||
| console.log(`Validating exports in ${inputDir}...`); | ||
| const result = validateStagingDir(inputDir); | ||
| if (result.issues.length === 0) { | ||
| console.log(`All checks passed (${result.skillCount} skills validated)`); | ||
| } else { | ||
| console.log(`${result.issues.length} issue(s) found across ${result.skillCount} skills:`); | ||
| for (const issue of result.issues) { | ||
| console.log(` ${issue.skill}/${issue.file}: ${issue.issue}`); | ||
| } | ||
| } | ||
| if (reportPath) { | ||
| const report = generateReport(inputDir, result); | ||
| const reportDir = path.dirname(reportPath); | ||
| fs.mkdirSync(reportDir, { recursive: true }); | ||
| fs.writeFileSync(reportPath, report); | ||
| console.log(`Validation report written to ${reportPath}`); | ||
| } | ||
| return result.issues.length === 0 ? 0 : 1; | ||
| } | ||
| if (require.main === module) { | ||
| process.exit(main()); | ||
| } | ||
| module.exports = { validateStagingDir, generateReport, main }; |
| submodule_name: _vortex | ||
| description: Vortex Pattern - Contextualize, Empathize, Externalize, and Systematize streams | ||
| module: bme | ||
| output_folder: '{project-root}/_bmad-output/vortex-artifacts' | ||
| output_folder: "{project-root}/_bmad-output/vortex-artifacts" | ||
| agents: | ||
@@ -36,4 +36,4 @@ - contextualization-expert | ||
| - vortex-navigation | ||
| version: 3.1.0 | ||
| user_name: '{user}' | ||
| version: 3.2.0 | ||
| user_name: "{user}" | ||
| communication_language: en | ||
@@ -43,3 +43,3 @@ party_mode_enabled: true | ||
| migration_history: | ||
| - timestamp: '2026-02-27T13:26:05.803Z' | ||
| - timestamp: 2026-02-27T13:26:05.803Z | ||
| from_version: 1.5.2 | ||
@@ -46,0 +46,0 @@ to_version: 1.6.2 |
+31
-0
@@ -10,2 +10,33 @@ # Changelog | ||
| ## [3.2.0] - 2026-04-11 | ||
| ### Added | ||
| - **Portability System** — Export BMAD skills to portable, LLM-agnostic format with platform adapters for Claude, Copilot, and Cursor. New CLI command `convoke-export` and 4 new skills: `bmad-export-skill`, `bmad-generate-catalog`, `bmad-seed-catalog`, `bmad-validate-exports`. | ||
| - **Portability Export Engine** (`scripts/portability/export-engine.js`) — Transforms skill workflows into standalone instructions documents with persona, sections, and configurable warning types. Supports tier classification (standalone, light-deps, pipeline). | ||
| - **Catalog Generator** — Generates a decision-tree skill catalog README from manifest data for the public catalog repository. | ||
| - **Seed Catalog** — Seeds a complete catalog repository staging directory with all exportable skills, adapters, and catalog README. | ||
| - **Export Validation** — Validates exported skill staging directories for structural correctness and BMAD-internal leaks. | ||
| - **Platform Adapters** — Generates Claude (CLAUDE.md commands), GitHub Copilot (`.github/copilot-instructions.md`), and Cursor (`.cursor/rules/`) adapter files from exported skills. | ||
| - **Team Factory module** — Now ships in the npm package (`_bmad/bme/_team-factory/`). Factory workflows for industrializing team, agent, and skill addition. | ||
| ### Changed | ||
| - **Test infrastructure recovery** — Resolved the C1 phantom-test bug class discovered in the 2026-04-08 astonishment report. 22 test files in `tests/lib/` converted from Jest API to `node:test` + `node:assert/strict`. Test gate grew from 408 to 1,123 verified-passing tests. | ||
| - **`npm test` glob** — Replaced 8 literal file paths with `tests/lib/*.test.js` glob. New test files in `tests/lib/` are now automatically discovered by CI. | ||
| - **eslint config** — Removed Jest globals (`test`, `expect`, `jest`, `beforeEach`, `afterEach`, `beforeAll`, `afterAll`) from `tests/**/*.js` block. This is the structural fix that prevents future phantom tests from passing lint. | ||
| - **`test:all` script** — Rewritten to compose `npm test && npm run test:integration && npm run test:p0` instead of a broken `tests/**/*.test.js` glob. | ||
| - **`test:p0:gate`** — Now delegates to `test:p0` instead of duplicating the command. | ||
| - **Definition of Done checklist** — Added Phantom Test Guard and Test Wiring Verified items to prevent AI-generated phantom tests from passing story completion. | ||
| - **Golden fixture protection** — `tests/**/golden/` directories excluded from eslint with explanatory README documenting the byte-exact contract. | ||
| ### Infrastructure | ||
| - **1,123 tests** across unit, integration, p0, team-factory, and lib suites (was 320+ in v3.1.0) | ||
| - **`tests/mock-cp.js` helper** — Shared child_process mock helper for `node:test` suites. Handles module-cache reset, per-spy restoration, and explicit caller-dirname resolution. | ||
| - **New dependency:** `yaml` ^2.8.3 (for YAML comment preservation in portability exports) | ||
| - **Published modules:** Added `_bmad/bme/_portability/` and `_bmad/bme/_team-factory/` to npm package files | ||
| --- | ||
| ## [3.1.0] - 2026-04-06 | ||
@@ -12,0 +43,0 @@ |
+12
-7
| { | ||
| "name": "convoke-agents", | ||
| "version": "3.1.0", | ||
| "version": "3.2.0", | ||
| "description": "Agent teams for complex systems, compatible with BMad Method", | ||
@@ -12,2 +12,5 @@ "main": "index.js", | ||
| "_bmad/bme/_gyre/", | ||
| "_bmad/bme/_artifacts/", | ||
| "_bmad/bme/_portability/", | ||
| "_bmad/bme/_team-factory/", | ||
| "INSTALLATION.md", | ||
@@ -29,3 +32,4 @@ "UPDATE-GUIDE.md", | ||
| "convoke-migrate-artifacts": "scripts/migrate-artifacts.js", | ||
| "convoke-portfolio": "scripts/lib/portfolio/portfolio-engine.js" | ||
| "convoke-portfolio": "scripts/lib/portfolio/portfolio-engine.js", | ||
| "convoke-export": "scripts/portability/convoke-export.js" | ||
| }, | ||
@@ -35,8 +39,8 @@ "scripts": { | ||
| "postinstall": "node scripts/postinstall.js", | ||
| "test": "node --test tests/unit/*.test.js", | ||
| "test": "node --test tests/unit/*.test.js tests/team-factory/*.test.js tests/lib/*.test.js", | ||
| "test:p0": "node --test tests/p0/*.test.js", | ||
| "test:p0:gate": "node --test tests/p0/*.test.js", | ||
| "test:p0:gate": "npm run test:p0", | ||
| "test:integration": "node --test tests/integration/*.test.js", | ||
| "test:all": "node --test tests/**/*.test.js", | ||
| "test:coverage": "c8 node --test tests/unit/*.test.js tests/integration/*.test.js tests/p0/*.test.js", | ||
| "test:all": "npm test && npm run test:integration && npm run test:p0", | ||
| "test:coverage": "c8 node --test tests/unit/*.test.js tests/team-factory/*.test.js tests/lib/*.test.js tests/integration/*.test.js tests/p0/*.test.js", | ||
| "lint": "eslint scripts/ index.js tests/", | ||
@@ -82,3 +86,4 @@ "docs:audit": "node scripts/docs-audit.js", | ||
| "gray-matter": "^4.0.3", | ||
| "js-yaml": "^4.1.0" | ||
| "js-yaml": "^4.1.0", | ||
| "yaml": "^2.8.3" | ||
| }, | ||
@@ -85,0 +90,0 @@ "devDependencies": { |
+1
-1
@@ -13,3 +13,3 @@ <div align="center"> | ||
| [](https://github.com/amalik/convoke-agents) | ||
| [](https://github.com/amalik/convoke-agents) | ||
| [](LICENSE) | ||
@@ -16,0 +16,0 @@ |
@@ -8,2 +8,7 @@ #!/usr/bin/env node | ||
| const { findProjectRoot, getPackageVersion } = require('./update/lib/utils'); | ||
| // Note: parseCsvRow is loaded LAZILY inside loadSkillManifest() (ag-7-2 review patch). | ||
| // Top-level require would crash the doctor on installs missing _team-factory/ — exactly | ||
| // the broken-install case the doctor exists to diagnose. The lazy require is wrapped | ||
| // in try/catch so a missing _team-factory degrades to "skip wrapper checks" instead | ||
| // of crashing the entire CLI. | ||
@@ -57,2 +62,6 @@ /** | ||
| // 3. Per-module checks | ||
| // ag-7-2 (I31): load skill-manifest once for the wrapper-name lookup used by | ||
| // checkModuleSkillWrappers below. Empty Map is safe (falls back to verbatim). | ||
| const manifestMap = loadSkillManifest(projectRoot); | ||
| for (const mod of modules) { | ||
@@ -68,2 +77,9 @@ const configCheck = checkModuleConfig(mod); | ||
| } | ||
| // ag-7-2 (I31): verify skill wrappers exist on disk for standalone-skill workflows. | ||
| // checkModuleSkillWrappers returns null if the module has no manifest entries | ||
| // (i.e., no standalone-skill workflows), so we filter those out. | ||
| if (Array.isArray(mod.config.workflows) && mod.config.workflows.length > 0) { | ||
| const wrapperCheck = checkModuleSkillWrappers(mod, projectRoot, manifestMap); | ||
| if (wrapperCheck) checks.push(wrapperCheck); | ||
| } | ||
| } | ||
@@ -256,2 +272,157 @@ } | ||
| /** | ||
| * Load skill-manifest.csv into a Map keyed by source path → canonicalId. | ||
| * The manifest is the authoritative source for wrapper directory names | ||
| * (canonicalId = the directory name under .claude/skills/). | ||
| * | ||
| * Returns an empty Map and logs a warning on any read/parse error — never throws, | ||
| * never fails the doctor. checkModuleSkillWrappers will fall back to verbatim | ||
| * workflow names for any source path not in the map. | ||
| * | ||
| * Closes I31 (ag-7-2). | ||
| * | ||
| * @param {string} projectRoot - Absolute path to project root | ||
| * @returns {Map<string, string>} sourcePath → canonicalId | ||
| */ | ||
| function loadSkillManifest(projectRoot) { | ||
| const manifestPath = path.join(projectRoot, '_bmad/_config/skill-manifest.csv'); | ||
| const map = new Map(); | ||
| if (!fs.existsSync(manifestPath)) { | ||
| console.warn(chalk.yellow(` ⚠ skill-manifest.csv not found at ${manifestPath}; skill wrapper checks will be skipped`)); | ||
| return map; | ||
| } | ||
| // Lazy-load parseCsvRow from the optional _team-factory submodule. | ||
| // ag-7-2 review patch (Edge Case Hunter EH#1): if _team-factory/ is missing | ||
| // (e.g., user opted out of the team-factory module), the top-level require | ||
| // would crash the doctor before main() even runs — exactly the broken-install | ||
| // case the doctor exists to diagnose. Lazy + try/catch degrades cleanly. | ||
| let parseCsvRow; | ||
| try { | ||
| ({ parseCsvRow } = require('../_bmad/bme/_team-factory/lib/utils/csv-utils')); | ||
| } catch (_err) { | ||
| console.warn(chalk.yellow(` ⚠ csv-utils unavailable (_team-factory submodule not installed); skill wrapper checks will be skipped`)); | ||
| return map; | ||
| } | ||
| try { | ||
| const content = fs.readFileSync(manifestPath, 'utf8'); | ||
| const lines = content.split('\n').filter(l => l.trim().length > 0); | ||
| if (lines.length === 0) return map; | ||
| // Parse header to find column indices | ||
| const header = parseCsvRow(lines[0]); | ||
| const canonicalIdIdx = header.indexOf('canonicalId'); | ||
| const pathIdx = header.indexOf('path'); | ||
| if (canonicalIdIdx === -1 || pathIdx === -1) { | ||
| console.warn(chalk.yellow(` ⚠ skill-manifest.csv missing required columns (canonicalId, path); skill wrapper checks will be skipped`)); | ||
| return map; | ||
| } | ||
| // Parse data rows | ||
| for (let i = 1; i < lines.length; i++) { | ||
| const fields = parseCsvRow(lines[i]); | ||
| if (fields.length <= Math.max(canonicalIdIdx, pathIdx)) continue; | ||
| const canonicalId = fields[canonicalIdIdx]; | ||
| const sourcePath = fields[pathIdx]; | ||
| if (canonicalId && sourcePath) { | ||
| map.set(sourcePath, canonicalId); | ||
| } | ||
| } | ||
| } catch (err) { | ||
| console.warn(chalk.yellow(` ⚠ skill-manifest.csv parse error (${err.message}); skill wrapper checks will be skipped`)); | ||
| return new Map(); | ||
| } | ||
| return map; | ||
| } | ||
| /** | ||
| * Check that every standalone-skill workflow declared in a module's config.yaml | ||
| * has a corresponding skill wrapper at .claude/skills/{canonicalId}/SKILL.md. | ||
| * | ||
| * Manifest-as-opt-in semantics: | ||
| * - The skill-manifest.csv is the OPT-IN marker for "this workflow is a standalone | ||
| * skill that needs a wrapper." Modules whose workflows are NOT in the manifest | ||
| * (Vortex, Gyre, team-factory, ...) generate menu-patch workflows or agent | ||
| * skills, NOT standalone wrappers — those modules are silently skipped here. | ||
| * - Only Enhance + Artifacts workflows are in the manifest today (after ag-7-2 | ||
| * Task 0 added the Enhance row). | ||
| * - If a module has SOME workflows in the manifest and SOME not, only the | ||
| * in-manifest ones are checked. | ||
| * | ||
| * Resolution: source path `_bmad/bme/{mod.name}/workflows/{workflowName}/SKILL.md` | ||
| * → manifest lookup → canonicalId is the wrapper directory name. | ||
| * | ||
| * Failures are aggregated into a single result (mirrors validateEnhanceModule | ||
| * pattern from Story 6.6) so the operator sees every missing wrapper at once. | ||
| * | ||
| * Returns null if the module has NO standalone-skill workflows (caller skips | ||
| * pushing null results into the checks array). | ||
| * | ||
| * Closes I31 (ag-7-2). | ||
| * | ||
| * @param {object} mod - Module descriptor from discoverModules() | ||
| * @param {string} projectRoot - Absolute path to project root | ||
| * @param {Map<string, string>} manifestMap - From loadSkillManifest() | ||
| * @returns {object|null} Doctor check result, or null if module has no standalone-skill workflows | ||
| */ | ||
| function checkModuleSkillWrappers(mod, projectRoot, manifestMap) { | ||
| const label = `${mod.name} skill wrappers`; | ||
| const workflowNames = mod.config.workflows; | ||
| const failures = []; | ||
| const checked = []; | ||
| // Build the source-path prefix from mod.dir (the absolute module directory | ||
| // discovered by discoverModules) relative to projectRoot. ag-7-2 review patch | ||
| // (Blind Hunter BH#1): the previous hardcoded `_bmad/bme/` prefix was correct | ||
| // for current callers (discoverModules only scans bme submodules) but would | ||
| // silently miss if a future caller scans a different team directory. | ||
| const moduleRelPath = path.relative(projectRoot, mod.dir); | ||
| for (const w of workflowNames) { | ||
| // Null/empty/object-without-name guard. ag-7-2 review patch (Edge Case | ||
| // Hunter EH#4 / Blind Hunter BH#7): a malformed config.yaml with | ||
| // `workflows: [null]` or `workflows: [{}]` would otherwise crash the doctor | ||
| // with a TypeError instead of producing a clean diagnostic. | ||
| if (!w) continue; | ||
| const wfName = typeof w === 'object' ? w.name : w; | ||
| if (!wfName || typeof wfName !== 'string') continue; | ||
| const sourcePath = `${moduleRelPath}/workflows/${wfName}/SKILL.md`; | ||
| // Manifest is opt-in: only workflows declared in skill-manifest.csv are | ||
| // standalone-skill workflows that need wrappers. Skip the rest silently. | ||
| const wrapperName = manifestMap.get(sourcePath); | ||
| if (!wrapperName) continue; | ||
| checked.push(wfName); | ||
| const wrapperPath = path.join(projectRoot, '.claude', 'skills', wrapperName, 'SKILL.md'); | ||
| if (!fs.existsSync(wrapperPath)) { | ||
| failures.push(`Missing skill wrapper for ${wfName}: .claude/skills/${wrapperName}/SKILL.md`); | ||
| } | ||
| } | ||
| // If no workflows in this module are standalone skills, skip the check entirely. | ||
| if (checked.length === 0) { | ||
| return null; | ||
| } | ||
| if (failures.length > 0) { | ||
| return { | ||
| name: label, | ||
| passed: false, | ||
| error: failures.join('; '), | ||
| fix: 'Run convoke-update to regenerate skill wrappers' | ||
| }; | ||
| } | ||
| return { | ||
| name: label, | ||
| passed: true, | ||
| info: `${checked.length} standalone-skill workflows have wrappers` | ||
| }; | ||
| } | ||
| async function checkOutputDir(projectRoot) { | ||
@@ -505,2 +676,2 @@ const outputDir = path.join(projectRoot, '_bmad-output'); | ||
| module.exports = { checkTaxonomy }; | ||
| module.exports = { checkTaxonomy, loadSkillManifest, checkModuleSkillWrappers }; |
@@ -30,3 +30,191 @@ #!/usr/bin/env node | ||
| // --- Story 6.3: Attribution helpers --- | ||
| /** | ||
| * Scan a corpus for any taxonomy initiative ID or alias as a whole word. | ||
| * Mirrors `_scanCorpusForInitiative` in artifact-utils.js — kept local to avoid | ||
| * a cross-module dependency from portfolio into the migration suggester. The | ||
| * `[a-z0-9-]` boundary class keeps kebab-case identifiers atomic so `pre-gyre` | ||
| * does not match `gyre`. | ||
| * | ||
| * @param {string} corpus - Lowercased text to scan | ||
| * @param {import('../types').TaxonomyConfig} taxonomy | ||
| * @returns {string|null} Resolved canonical initiative ID, or null | ||
| */ | ||
| function _scanCorpus(corpus, taxonomy) { | ||
| if (!corpus || !taxonomy || !taxonomy.initiatives) return null; | ||
| const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : []; | ||
| const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : []; | ||
| const allInitiatives = [...platform, ...user]; | ||
| const aliasKeys = taxonomy.aliases ? Object.keys(taxonomy.aliases) : []; | ||
| const candidates = [...allInitiatives, ...aliasKeys].sort((a, b) => b.length - a.length); | ||
| for (const candidate of candidates) { | ||
| const escaped = candidate.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); | ||
| const re = new RegExp(`(?:^|[^a-z0-9-])${escaped}(?:$|[^a-z0-9-])`, 'i'); | ||
| if (re.test(corpus)) { | ||
| if (allInitiatives.includes(candidate)) return candidate; | ||
| if (taxonomy.aliases && taxonomy.aliases[candidate]) return taxonomy.aliases[candidate]; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** | ||
| * Map of story-key filename prefixes (that are NOT taxonomy initiatives) to | ||
| * canonical initiative IDs. Story files (e.g. `tf-2-10-...`, `p3-1-1-...`) live | ||
| * in `implementation-artifacts/` and use compact prefixes that aren't real | ||
| * initiative IDs. Real-initiative prefixes (`gyre`, `forge`, `helm`, etc.) are | ||
| * resolved separately by checking taxonomy membership directly. | ||
| * | ||
| * Add new entries here when a new initiative starts producing stories under a | ||
| * non-initiative prefix. | ||
| */ | ||
| const STORY_PREFIX_MAP = Object.freeze({ | ||
| ag: 'convoke', // Artifact Governance — platform-level work | ||
| tf: 'loom', // Team Factory → Loom | ||
| p2: 'convoke', // Phase 2 — platform stabilization | ||
| p3: 'convoke', // Phase 3 — Convoke rename | ||
| enh: 'enhance', // Enhance module | ||
| sp: 'convoke', // Skill Portability — platform-level | ||
| }); | ||
| /** | ||
| * Folder defaults for the portfolio engine. Mirrors the migration suggester: | ||
| * `planning-artifacts` is for cross-cutting convoke platform artifacts. | ||
| * Other dirs (`vortex-artifacts`, `implementation-artifacts`) are heterogeneous | ||
| * — no safe default, so they fall through to subsequent inference layers. | ||
| */ | ||
| const PORTFOLIO_FOLDER_DEFAULT_MAP = Object.freeze({ | ||
| 'planning-artifacts': 'convoke' | ||
| }); | ||
| /** | ||
| * Attribute a file to an initiative when filename inference fails. | ||
| * Six-step priority chain (highest first): | ||
| * 1. frontmatter.title keyword scan → 'frontmatter-title' | ||
| * 2. first 5 lines of content keyword scan → 'content-fallback' | ||
| * 3. filename first-segment matches a real taxonomy initiative (e.g. `gyre-1-1-...`) | ||
| * → 'filename-prefix' | ||
| * 4. parent directory name (e.g. `gyre-artifacts/`) → 'parent-dir' | ||
| * 5. story-key prefix mapping (e.g. `tf-2-10-...` → loom) → 'story-prefix' | ||
| * 6. folder default (e.g. `planning-artifacts/` → convoke) → 'folder-default' | ||
| * | ||
| * @param {Object} fileInfo - File descriptor with filename and dir | ||
| * @param {string} content - Already-loaded file content | ||
| * @param {Object|null} frontmatter - Parsed frontmatter or null | ||
| * @param {import('../types').TaxonomyConfig} taxonomy | ||
| * @returns {{initiative: string|null, source: string|null}} | ||
| */ | ||
| function attributeFile(fileInfo, content, frontmatter, taxonomy) { | ||
| // Step 1: Frontmatter title scan (highest priority) | ||
| if (frontmatter && typeof frontmatter.title === 'string' && frontmatter.title.length > 0) { | ||
| const match = _scanCorpus(frontmatter.title.toLowerCase(), taxonomy); | ||
| if (match) return { initiative: match, source: 'frontmatter-title' }; | ||
| } | ||
| // Step 2: First-5-lines content scan | ||
| // Strip frontmatter before scanning so non-title fields (e.g. `tags: [gyre]`) | ||
| // don't false-trigger content-fallback. The migration suggester does the same. | ||
| if (content && content.length > 0) { | ||
| let body = content; | ||
| try { | ||
| const parsed = parseFrontmatter(content); | ||
| if (parsed && typeof parsed.content === 'string') { | ||
| body = parsed.content; | ||
| } | ||
| } catch { | ||
| // Frontmatter parse failed — fall back to raw content | ||
| } | ||
| const corpus = body.split('\n').slice(0, 5).join(' ').toLowerCase(); | ||
| const match = _scanCorpus(corpus, taxonomy); | ||
| if (match) return { initiative: match, source: 'content-fallback' }; | ||
| } | ||
| // Step 3: Filename first-segment matches a real taxonomy initiative. | ||
| // Catches patterns like `gyre-1-1-...`, `forge-epic-1-...`, `helm-prd.md`, | ||
| // and also single-word filenames like `gyre.md` (extension stripped first). | ||
| if (fileInfo.filename && taxonomy && taxonomy.initiatives) { | ||
| const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : []; | ||
| const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : []; | ||
| const allInitiatives = new Set([...platform, ...user]); | ||
| const stem = fileInfo.filename.replace(/\.(md|yaml|yml)$/i, ''); | ||
| const firstSegment = stem.split('-')[0].toLowerCase(); | ||
| if (allInitiatives.has(firstSegment)) { | ||
| return { initiative: firstSegment, source: 'filename-prefix' }; | ||
| } | ||
| } | ||
| // Step 4: Parent directory scan | ||
| // The dir is something like `gyre-artifacts` — split on dashes and check each | ||
| // segment against the taxonomy. We can't reuse `_scanCorpus` here because it | ||
| // uses a kebab-aware boundary class (`[a-z0-9-]`) that intentionally treats | ||
| // `gyre-artifacts` as a single token, which is correct for content scanning | ||
| // but wrong for directory naming where `-` IS the segment separator. | ||
| if (fileInfo.dir && taxonomy && taxonomy.initiatives) { | ||
| const platform = Array.isArray(taxonomy.initiatives.platform) ? taxonomy.initiatives.platform : []; | ||
| const user = Array.isArray(taxonomy.initiatives.user) ? taxonomy.initiatives.user : []; | ||
| const allInitiatives = new Set([...platform, ...user]); | ||
| const aliases = taxonomy.aliases || {}; | ||
| const dirSegments = fileInfo.dir.toLowerCase().split('-'); | ||
| for (const seg of dirSegments) { | ||
| if (allInitiatives.has(seg)) { | ||
| return { initiative: seg, source: 'parent-dir' }; | ||
| } | ||
| if (aliases[seg]) { | ||
| return { initiative: aliases[seg], source: 'parent-dir' }; | ||
| } | ||
| } | ||
| } | ||
| // Step 5: Story-key prefix mapping | ||
| // Match patterns like `tf-2-10-...`, `p3-epic-2-retrospective`, `ag-1-1-...` | ||
| // Take the first dash-separated segment of the filename stem and look it up in STORY_PREFIX_MAP. | ||
| if (fileInfo.filename) { | ||
| const stem = fileInfo.filename.replace(/\.(md|yaml|yml)$/i, ''); | ||
| const firstSegment = stem.split('-')[0].toLowerCase(); | ||
| if (Object.prototype.hasOwnProperty.call(STORY_PREFIX_MAP, firstSegment)) { | ||
| return { initiative: STORY_PREFIX_MAP[firstSegment], source: 'story-prefix' }; | ||
| } | ||
| } | ||
| // Step 6: Folder default — last resort for cross-cutting platform artifacts | ||
| // (e.g. `planning-artifacts/architecture.md` → convoke) | ||
| if (fileInfo.dir && Object.prototype.hasOwnProperty.call(PORTFOLIO_FOLDER_DEFAULT_MAP, fileInfo.dir)) { | ||
| const defaultInit = PORTFOLIO_FOLDER_DEFAULT_MAP[fileInfo.dir]; | ||
| if (defaultInit) { | ||
| return { initiative: defaultInit, source: 'folder-default' }; | ||
| } | ||
| } | ||
| return { initiative: null, source: null }; | ||
| } | ||
| /** | ||
| * Produce a one-line reason explaining why a file could not be attributed. | ||
| * | ||
| * @param {Object} fileInfo - File descriptor | ||
| * @param {string} content - File content (may be empty if unreadable) | ||
| * @param {Object|null} _frontmatter - Frontmatter (reserved for future heuristics) | ||
| * @returns {string} Reason | ||
| */ | ||
| function explainUnattributed(fileInfo, content, _frontmatter) { | ||
| if (!content || content.length === 0) { | ||
| return 'unreadable or empty'; | ||
| } | ||
| // Check whether the filename has a recognizable type prefix BEFORE checking content length — | ||
| // matches the spec's order so a 3-line file with no prefix returns the more actionable | ||
| // 'no type prefix' message rather than 'insufficient content'. | ||
| const hasTypePrefix = /^[a-z]+\d*-/.test(fileInfo.filename); | ||
| if (!hasTypePrefix) { | ||
| return 'no type prefix in filename'; | ||
| } | ||
| const lineCount = content.split('\n').length; | ||
| if (lineCount < 5) { | ||
| return 'insufficient content for inference'; | ||
| } | ||
| return 'no initiative signal in filename, frontmatter title, content, or parent directory'; | ||
| } | ||
| /** | ||
| * Create an empty InitiativeState for a given initiative. | ||
@@ -77,3 +265,4 @@ * @param {string} initiative - Initiative ID | ||
| let ungoverned = 0; | ||
| let unattributed = 0; | ||
| let attributableButUngoverned = 0; // Story 6.3: counts files attributed via fallback layers | ||
| const unattributedFiles = []; // Story 6.3: track per-file reasons (replaces bare counter) | ||
@@ -84,16 +273,6 @@ const mdFiles = allFiles.filter(f => f.filename.endsWith('.md')); | ||
| const typeResult = inferArtifactType(file.filename, taxonomy); | ||
| const initResult = typeResult.type | ||
| ? inferInitiative(typeResult.remainder, taxonomy) | ||
| : { initiative: null, confidence: 'low', source: 'no-type', candidates: [] }; | ||
| // Files with no resolved initiative cannot be attributed — skip | ||
| if (!initResult.initiative) { | ||
| unattributed++; | ||
| continue; | ||
| } | ||
| // Read frontmatter to classify governed vs ungoverned | ||
| // Read frontmatter FIRST — governed files have authoritative metadata | ||
| let frontmatter = null; | ||
| let content = ''; | ||
| let readFailed = false; | ||
| try { | ||
@@ -103,11 +282,60 @@ content = fs.readFileSync(file.fullPath, 'utf8'); | ||
| } catch { | ||
| // Unreadable — treat as no frontmatter | ||
| // Unreadable — treat as no frontmatter and skip fallback attribution | ||
| // (otherwise filename-prefix/story-prefix could silently attribute a file we never read) | ||
| readFailed = true; | ||
| } | ||
| // Governed = has frontmatter with matching initiative field | ||
| const isGoverned = !!(frontmatter && frontmatter.initiative && frontmatter.initiative === initResult.initiative); | ||
| if (isGoverned) { | ||
| // Strategy: frontmatter initiative is authoritative if present | ||
| let initiative, artifactType, isGoverned, typeResult; | ||
| let attributionSource = null; // Story 6.3: tracks which fallback layer provided the attribution | ||
| if (frontmatter && frontmatter.initiative && frontmatter.artifact_type) { | ||
| // Governed file — use frontmatter as source of truth | ||
| initiative = frontmatter.initiative; | ||
| artifactType = frontmatter.artifact_type; | ||
| isGoverned = true; | ||
| governed++; | ||
| typeResult = { type: artifactType, hcPrefix: null, remainder: '', date: null, typeConfidence: 'high', typeSource: 'frontmatter' }; | ||
| } else { | ||
| ungoverned++; | ||
| // Ungoverned file — fall back to filename inference | ||
| typeResult = inferArtifactType(file.filename, taxonomy); | ||
| const initResult = typeResult.type | ||
| ? inferInitiative(typeResult.remainder, taxonomy) | ||
| : { initiative: null, confidence: 'low', source: 'no-type', candidates: [] }; | ||
| if (initResult.initiative) { | ||
| initiative = initResult.initiative; | ||
| artifactType = typeResult.type; | ||
| isGoverned = false; | ||
| ungoverned++; | ||
| } else if (readFailed) { | ||
| // Don't attempt fallback attribution on a file we couldn't read — | ||
| // otherwise filename/story-prefix layers would silently produce a phantom attribution. | ||
| unattributedFiles.push({ | ||
| filename: file.filename, | ||
| dir: file.dir, | ||
| reason: 'unreadable or empty' | ||
| }); | ||
| continue; | ||
| } else { | ||
| // Story 6.3: try content-fallback layers before giving up | ||
| const fallback = attributeFile(file, content, frontmatter, taxonomy); | ||
| if (fallback.initiative) { | ||
| initiative = fallback.initiative; | ||
| // Type may not be inferable — synthetic 'unknown' so registry can still index it | ||
| artifactType = typeResult.type || 'unknown'; | ||
| isGoverned = false; | ||
| ungoverned++; | ||
| attributableButUngoverned++; | ||
| attributionSource = fallback.source; | ||
| } else { | ||
| // Truly unattributed — record reason for the diagnostic section | ||
| unattributedFiles.push({ | ||
| filename: file.filename, | ||
| dir: file.dir, | ||
| reason: explainUnattributed(file, content, frontmatter) | ||
| }); | ||
| continue; | ||
| } | ||
| } | ||
| } | ||
@@ -119,17 +347,20 @@ | ||
| fullPath: file.fullPath, | ||
| type: typeResult.type, | ||
| type: artifactType, | ||
| hcPrefix: typeResult.hcPrefix, | ||
| date: typeResult.date, | ||
| initiative: initResult.initiative, | ||
| initiative, | ||
| frontmatter, | ||
| content, | ||
| isGoverned, | ||
| degradedMode: !isGoverned | ||
| degradedMode: !isGoverned, | ||
| // Story 6.3: 'frontmatter-title' | 'content-fallback' | 'filename-prefix' | 'parent-dir' | 'story-prefix' | 'folder-default' | null | ||
| attributionSource | ||
| }; | ||
| if (!registry.has(initResult.initiative)) { | ||
| registry.set(initResult.initiative, []); | ||
| if (!registry.has(initiative)) { | ||
| registry.set(initiative, []); | ||
| } | ||
| registry.get(initResult.initiative).push(enriched); | ||
| registry.get(initiative).push(enriched); | ||
| } | ||
| const unattributed = unattributedFiles.length; | ||
@@ -188,2 +419,3 @@ // FR39: warn if no governed artifacts | ||
| wipRadar, | ||
| unattributedFiles, // Story 6.3: full list with reasons (for --show-unattributed) | ||
| summary: { | ||
@@ -194,2 +426,3 @@ total: mdFiles.length, | ||
| unattributed, | ||
| attributableButUngoverned, // Story 6.3: count of files attributed via fallback layers | ||
| healthScore: { governed, total: attributable, percentage: healthPercentage } | ||
@@ -209,13 +442,15 @@ } | ||
| Options: | ||
| --terminal Terminal table output (default) | ||
| --markdown Markdown table output | ||
| --sort <mode> Sort: alpha (default), last-activity | ||
| --filter <prefix> Filter initiatives by prefix (e.g., --filter gyre) | ||
| --verbose Show inference trace per initiative (source + confidence) | ||
| --help, -h Show this help | ||
| --terminal Terminal table output (default) | ||
| --markdown Markdown table output | ||
| --sort <mode> Sort: alpha (default), last-activity | ||
| --filter <prefix> Filter initiatives by prefix (e.g., --filter gyre) | ||
| --verbose Show inference trace per initiative (source + confidence) | ||
| --show-unattributed List each unattributed file with its reason | ||
| --help, -h Show this help | ||
| Examples: | ||
| convoke-portfolio Default terminal view | ||
| convoke-portfolio --markdown Markdown output for chat/docs | ||
| convoke-portfolio --sort last-activity Sort by most recent activity | ||
| convoke-portfolio Default terminal view | ||
| convoke-portfolio --markdown Markdown output for chat/docs | ||
| convoke-portfolio --sort last-activity Sort by most recent activity | ||
| convoke-portfolio --show-unattributed See why each unattributed file was skipped | ||
| `); | ||
@@ -240,2 +475,3 @@ } | ||
| const useVerbose = args.includes('--verbose'); | ||
| const showUnattributed = args.includes('--show-unattributed'); | ||
| const sortMode = args.includes('--sort') && args[args.indexOf('--sort') + 1] === 'last-activity' | ||
@@ -292,2 +528,25 @@ ? 'last-activity' | ||
| // Story 6.3: Surface attributable-but-ungoverned guidance | ||
| if (result.summary.attributableButUngoverned > 0) { | ||
| console.log( | ||
| `${result.summary.attributableButUngoverned} files attributable to existing initiatives ` + | ||
| `but ungoverned — run convoke-migrate-artifacts to govern them` | ||
| ); | ||
| } | ||
| // Story 6.3: Unattributed details | ||
| if (result.unattributedFiles && result.unattributedFiles.length > 0) { | ||
| if (showUnattributed) { | ||
| console.log(`\n--- Unattributed Files (${result.unattributedFiles.length}) ---`); | ||
| for (const u of result.unattributedFiles) { | ||
| console.log(` ${u.dir}/${u.filename}: ${u.reason}`); | ||
| } | ||
| } else { | ||
| console.log( | ||
| `\n${result.unattributedFiles.length} unattributed files ` + | ||
| `(run with --show-unattributed to see details)` | ||
| ); | ||
| } | ||
| } | ||
| // Verbose: inference trace per initiative | ||
@@ -315,2 +574,10 @@ if (useVerbose) { | ||
| module.exports = { generatePortfolio, makeEmptyState, EXCLUDE_DIRS }; | ||
| module.exports = { | ||
| generatePortfolio, | ||
| makeEmptyState, | ||
| attributeFile, | ||
| explainUnattributed, | ||
| EXCLUDE_DIRS, | ||
| STORY_PREFIX_MAP, | ||
| PORTFOLIO_FOLDER_DEFAULT_MAP | ||
| }; |
@@ -93,4 +93,5 @@ /** | ||
| // No recognized pattern | ||
| state.phase = { value: 'unknown', source: 'artifact-chain', confidence: 'inferred' }; | ||
| // No recognized pattern — collect evidence so the operator can see WHY it's unknown (Story 6.3) | ||
| const evidence = collectPhaseEvidence(artifacts, types, hcPrefixes); | ||
| state.phase = { value: 'unknown', source: 'artifact-chain', confidence: 'inferred', evidence }; | ||
| return state; | ||
@@ -100,2 +101,31 @@ } | ||
| /** | ||
| * Collect a one-line description of what the phase inference looked at when it | ||
| * couldn't determine a phase. Used to populate the Next Action with context | ||
| * instead of the generic "Create PRD or brief" message. | ||
| * | ||
| * @param {Array<Object>} artifacts - All artifacts for this initiative | ||
| * @param {Set<string>} types - Distinct artifact types present | ||
| * @param {Set<string>} hcPrefixes - HC prefixes present (e.g. 'hc1', 'hc2') | ||
| * @returns {string[]} Evidence list, e.g. ["3 artifacts found", "no PRD/brief", "no HC chain", ...] | ||
| */ | ||
| function collectPhaseEvidence(artifacts, types, hcPrefixes) { | ||
| const evidence = []; | ||
| const count = artifacts.length; | ||
| evidence.push(count === 1 ? '1 artifact found' : `${count} artifacts found`); | ||
| // Special-case: only HC1 present (incomplete discovery, doesn't trigger discovery branch) | ||
| if (hcPrefixes.size === 1 && hcPrefixes.has('hc1')) { | ||
| evidence.push('incomplete HC chain (needs HC2-HC6)'); | ||
| return evidence; | ||
| } | ||
| if (!types.has('prd') && !types.has('brief')) evidence.push('no PRD/brief'); | ||
| if (!types.has('arch')) evidence.push('no architecture'); | ||
| if (hcPrefixes.size === 0) evidence.push('no HC chain'); | ||
| if (!types.has('epic')) evidence.push('no epic'); | ||
| return evidence; | ||
| } | ||
| /** | ||
| * Check if epic content indicates completion via flexible markers. | ||
@@ -128,2 +158,2 @@ * @param {string} content - Epic file content | ||
| module.exports = { applyArtifactChainRule, isEpicDone, detectHCChain, DONE_PATTERNS }; | ||
| module.exports = { applyArtifactChainRule, isEpicDone, detectHCChain, collectPhaseEvidence, DONE_PATTERNS }; |
@@ -35,2 +35,24 @@ /** | ||
| // Story 6.3: If phase is unknown AND a recognized-type artifact exists AND we have evidence, | ||
| // surface a context-aware next action instead of the generic "Create PRD or brief". | ||
| // - Initiatives with zero artifacts still get the generic message (legitimate use case). | ||
| // - Initiatives whose ONLY artifacts are fallback-attributed (synthetic 'unknown' type) | ||
| // also get the generic message — those don't reflect a real phase signal worth elaborating on. | ||
| // This guards against the design-intent inversion caught in code review: | ||
| // a single fallback-attributed note shouldn't override "Create PRD or brief". | ||
| const hasRecognizedArtifact = artifacts.some(a => a && a.type && a.type !== 'unknown'); | ||
| if ( | ||
| state.phase.value === 'unknown' && | ||
| Array.isArray(state.phase.evidence) && | ||
| state.phase.evidence.length > 0 && | ||
| hasRecognizedArtifact | ||
| ) { | ||
| const summary = state.phase.evidence.slice(0, 2).join(', '); | ||
| state.nextAction = { | ||
| value: `Unknown phase: ${summary}`, | ||
| source: 'conflict-resolver' | ||
| }; | ||
| return state; | ||
| } | ||
| // Derive nextAction from phase if not already set by chain-gap analysis | ||
@@ -37,0 +59,0 @@ if (!state.nextAction.value) { |
@@ -26,2 +26,3 @@ #!/usr/bin/env node | ||
| resolveAmbiguous, | ||
| loadResolutionMap, | ||
| detectMigrationState, | ||
@@ -43,3 +44,3 @@ generateGovernanceADR, | ||
| * @param {string[]} argv - Arguments (typically process.argv.slice(2)) | ||
| * @returns {{help: boolean, includeDirs: string[], apply: boolean, force: boolean, verbose: boolean}} | ||
| * @returns {{help: boolean, includeDirs: string[], apply: boolean, force: boolean, verbose: boolean, resolutionFile: string|null, resolutionFileError: string|null}} | ||
| */ | ||
@@ -71,3 +72,33 @@ function parseArgs(argv) { | ||
| return { help, includeDirs, apply, force, verbose }; | ||
| // Story 6.4: --resolution-file <path> — operator decisions for AMBIGUOUS entries. | ||
| // Loaded and validated in main() after the manifest is generated. | ||
| // Also accept the GNU --resolution-file=path form. Reject missing/empty/flag-like | ||
| // values loudly via parse error so operators don't accidentally run a destructive | ||
| // --apply --force without their overrides being honored. | ||
| let resolutionFile = null; | ||
| let resolutionFileError = null; | ||
| // Check the equals form first: --resolution-file=path | ||
| const eqArg = argv.find(a => a.startsWith('--resolution-file=')); | ||
| if (eqArg) { | ||
| const value = eqArg.slice('--resolution-file='.length); | ||
| if (!value || value.startsWith('-')) { | ||
| resolutionFileError = `--resolution-file requires a non-empty path (got: '${value}')`; | ||
| } else { | ||
| resolutionFile = value; | ||
| } | ||
| } else { | ||
| const resolutionIdx = argv.indexOf('--resolution-file'); | ||
| if (resolutionIdx !== -1) { | ||
| const nextArg = argv[resolutionIdx + 1]; | ||
| // Reject: missing arg, empty arg, anything starting with `-` (covers --flags AND -singledash) | ||
| if (!nextArg || nextArg.startsWith('-')) { | ||
| resolutionFileError = `--resolution-file requires a path argument (got: '${nextArg || '<missing>'}')`; | ||
| } else { | ||
| resolutionFile = nextArg; | ||
| } | ||
| } | ||
| } | ||
| return { help, includeDirs, apply, force, verbose, resolutionFile, resolutionFileError }; | ||
| } | ||
@@ -85,8 +116,11 @@ | ||
| Options: | ||
| --include <dirs> Comma-separated directory names to scan (relative to _bmad-output/) | ||
| Default: planning-artifacts,vortex-artifacts,gyre-artifacts | ||
| --verbose Show cross-references for ambiguous files | ||
| --apply Execute the rename migration (commit 1: git mv) | ||
| --force Bypass confirmation prompt (use with --apply for automation) | ||
| --help, -h Show this help | ||
| --include <dirs> Comma-separated directory names to scan (relative to _bmad-output/) | ||
| Default: planning-artifacts,vortex-artifacts,gyre-artifacts | ||
| --verbose Show cross-references for ambiguous files | ||
| --apply Execute the rename migration (commit 1: git mv) | ||
| --force Bypass confirmation prompt (use with --apply for automation) | ||
| --resolution-file <path> JSON file with operator decisions for ambiguous entries. | ||
| Combined with --force gives a fully non-interactive run. | ||
| Schema: { "schemaVersion": 1, "resolutions": { "dir/file.md": { "action": "rename", "initiative": "convoke" } } } | ||
| --help, -h Show this help | ||
@@ -97,2 +131,3 @@ Examples: | ||
| convoke-migrate-artifacts --include planning-artifacts Dry-run for one directory | ||
| convoke-migrate-artifacts --apply --force --resolution-file resolutions.json Non-interactive apply with operator decisions | ||
| `); | ||
@@ -180,2 +215,9 @@ } | ||
| // Story 6.4: fail fast on a malformed --resolution-file flag so a typo can't | ||
| // silently turn into a destructive --apply --force run with no overrides applied. | ||
| if (args.resolutionFileError) { | ||
| console.error(`Error: ${args.resolutionFileError}`); | ||
| process.exit(1); | ||
| } | ||
| if (args.force && !args.apply) { | ||
@@ -256,4 +298,21 @@ console.log('Warning: --force has no effect without --apply. Running dry-run instead.'); | ||
| // Resolve ambiguous files interactively (or auto-skip in --force mode) | ||
| const resolution = await resolveAmbiguous(manifest, taxonomy, projectRoot, { force: args.force }); | ||
| // Story 6.4: optional pre-loaded operator resolutions via --resolution-file. | ||
| // Loaded once here so any validation error fails fast before we touch the manifest. | ||
| let resolutionMap = null; | ||
| if (args.resolutionFile) { | ||
| try { | ||
| resolutionMap = loadResolutionMap(args.resolutionFile, taxonomy); | ||
| const count = Object.keys(resolutionMap).length; | ||
| console.log(`Loaded ${count} operator resolution(s) from ${args.resolutionFile}.`); | ||
| } catch (err) { | ||
| console.error(`Error loading resolution file: ${err.message}`); | ||
| process.exit(1); | ||
| } | ||
| } | ||
| // Resolve ambiguous files: resolution map (if any) → no-candidates skip → force skip → interactive prompt | ||
| const resolution = await resolveAmbiguous(manifest, taxonomy, projectRoot, { | ||
| force: args.force, | ||
| resolutionMap | ||
| }); | ||
| if (resolution.resolved > 0 || resolution.skipped > 0) { | ||
@@ -260,0 +319,0 @@ console.log(`\nAmbiguous resolution: ${resolution.resolved} resolved, ${resolution.skipped} skipped.`); |
@@ -201,2 +201,35 @@ /** | ||
| // Standalone bme agents that don't fit the Vortex/Gyre team pattern. | ||
| // These agents live in their own submodule (not _vortex or _gyre) and are | ||
| // individually registered. refresh-installation.js and validator.js both | ||
| // consume this list to preserve and validate them. | ||
| // | ||
| // Each entry must include: | ||
| // - id: kebab-case identifier (becomes bmad-agent-bme-{id}) | ||
| // - submodule: directory under _bmad/bme/ (e.g., '_team-factory') | ||
| // - name: displayName for manifest | ||
| // - title: persona title | ||
| // - icon: emoji | ||
| // - role: persona role string | ||
| // - identity: persona identity description | ||
| // - communication_style: persona voice | ||
| // - expertise: principles/expertise bullets | ||
| const EXTRA_BME_AGENTS = [ | ||
| { | ||
| id: 'team-factory', | ||
| submodule: '_team-factory', | ||
| name: 'Loom Master', | ||
| title: 'Team Factory', | ||
| icon: '🏭', | ||
| persona: { | ||
| role: 'Team Architecture Specialist + BMAD Compliance Expert', | ||
| identity: 'Master team architect who guides framework contributors through creating fully-wired, BMAD-compliant teams. Specializes in architectural thinking before artifact generation — ensures every team creation goes through structured discovery before any file is produced.', | ||
| communication_style: 'Methodical yet encouraging — like a senior architect pair-programming with a colleague. Asks focused questions, explains trade-offs clearly, and celebrates good decisions. Uses concrete examples from Vortex and Gyre to illustrate patterns. Never dumps all decisions at once — progressive disclosure, one step at a time.', | ||
| expertise: "- Thinking before files: every team creation goes through discovery before generation - BMAD compliance is non-negotiable: output must be indistinguishable from native teams - No orphaned artifacts: if a file is created, it must be registered, wired, and discoverable - Delegate to BMB for artifact generation: factory owns integration wiring only - Validate continuously: don't wait until the end to check" | ||
| } | ||
| } | ||
| ]; | ||
| const EXTRA_BME_AGENT_IDS = EXTRA_BME_AGENTS.map(a => a.id); | ||
| module.exports = { | ||
@@ -215,2 +248,4 @@ AGENTS, | ||
| GYRE_WORKFLOW_NAMES, | ||
| EXTRA_BME_AGENTS, | ||
| EXTRA_BME_AGENT_IDS, | ||
| }; |
@@ -5,3 +5,5 @@ #!/usr/bin/env node | ||
| const yaml = require('js-yaml'); | ||
| const YAML = require('yaml'); // Comment-preserving YAML library (ag-7-1: I29). Used by mergeConfig + writeConfig to preserve comments across the merge round-trip. | ||
| const { AGENT_IDS, WORKFLOW_NAMES } = require('./agent-registry'); | ||
| const { assertVersion } = require('./utils'); | ||
@@ -11,4 +13,12 @@ /** | ||
| * Smart YAML merging preserving user settings | ||
| * | ||
| * ag-7-1 (I29): mergeConfig now returns a sentinel-tagged structure that carries | ||
| * the parsed YAML.Document alongside the merged plain-object form. writeConfig | ||
| * detects the sentinel and writes via the Document API (preserving comments) when | ||
| * possible, falling back to js-yaml.dump for backwards compatibility with any | ||
| * caller that passes a bare object. | ||
| */ | ||
| const MERGED_DOC_SENTINEL = Symbol.for('convoke.config-merger.docMerged'); | ||
| /** | ||
@@ -23,6 +33,9 @@ * Merge current config with new template while preserving user preferences. | ||
| * @param {object} updates - Updates to apply (agents, workflows, etc.) | ||
| * @returns {Promise<object>} Merged config object | ||
| * @returns {Promise<object>} Merged config object (with hidden Document sentinel for comment preservation) | ||
| */ | ||
| async function mergeConfig(currentConfigPath, newVersion, updates = {}) { | ||
| assertVersion(newVersion, 'config-merger'); // ag-7-1: I30 — fail fast on undefined/null/empty version | ||
| let current = {}; | ||
| let doc = null; // YAML.Document for comment preservation | ||
@@ -33,7 +46,16 @@ // Read current config if it exists | ||
| const currentContent = fs.readFileSync(currentConfigPath, 'utf8'); | ||
| const parsed = yaml.load(currentContent); | ||
| current = (parsed && typeof parsed === 'object') ? parsed : {}; | ||
| doc = YAML.parseDocument(currentContent); | ||
| // Note: YAML.parseDocument does not throw on syntax errors — check doc.errors | ||
| if (doc.errors && doc.errors.length > 0) { | ||
| console.warn(`Warning: Could not parse current config.yaml (${doc.errors[0].message}), using defaults`); | ||
| current = {}; | ||
| doc = null; | ||
| } else { | ||
| const parsed = doc.toJSON(); | ||
| current = (parsed && typeof parsed === 'object') ? parsed : {}; | ||
| } | ||
| } catch (_error) { | ||
| console.warn('Warning: Could not parse current config.yaml, using defaults'); | ||
| current = {}; | ||
| doc = null; | ||
| } | ||
@@ -87,2 +109,14 @@ } | ||
| // ag-7-1 (I29): attach the Document for comment-preserving writes. | ||
| // writeConfig detects the sentinel and writes via doc.toString() when set; | ||
| // otherwise it falls back to js-yaml.dump (backwards compat). | ||
| if (doc) { | ||
| Object.defineProperty(merged, MERGED_DOC_SENTINEL, { | ||
| value: doc, | ||
| enumerable: false, | ||
| writable: false, | ||
| configurable: false | ||
| }); | ||
| } | ||
| return merged; | ||
@@ -202,14 +236,94 @@ } | ||
| /** | ||
| * Write config to file | ||
| * Write config to file. | ||
| * If the config object carries the merged-doc sentinel from mergeConfig (ag-7-1), | ||
| * write via the Document API to preserve comments. Otherwise fall back to | ||
| * js-yaml.dump for backwards compatibility. | ||
| * | ||
| * @param {string} configPath - Path to write config | ||
| * @param {object} config - Config object | ||
| * @param {object} config - Config object (optionally carrying a Document sentinel) | ||
| * @returns {Promise<void>} | ||
| */ | ||
| async function writeConfig(configPath, config) { | ||
| const yamlContent = yaml.dump(config, { | ||
| indent: 2, | ||
| lineWidth: -1, // Don't wrap long lines | ||
| noRefs: true | ||
| }); | ||
| // ag-7-1 (I29) — Comment preservation paths, in order of preference: | ||
| // | ||
| // 1. SENTINEL PATH: caller went through `mergeConfig` which attached the parsed | ||
| // Document via the MERGED_DOC_SENTINEL symbol. Use it directly. The sentinel | ||
| // contract guarantees `merged` is a complete config (mergeConfig produces a | ||
| // full structure), so it's safe to delete keys from the Document that aren't | ||
| // in `merged` (e.g., a removed user field). | ||
| // 2. SELF-HEAL PATH: caller passed a bare object (e.g., `migration-runner.js`'s | ||
| // `updateMigrationHistory` calling `addMigrationHistory` then `writeConfig`) | ||
| // AND the destination file exists. Re-parse the existing file as a Document | ||
| // so any comments inside it survive the rewrite. CRITICAL: in the self-heal | ||
| // path, we ONLY apply additive/update operations (doc.set for each key the | ||
| // caller knows about) — we do NOT delete keys the caller doesn't mention, | ||
| // because the bare-object caller may not know about every field on disk | ||
| // (e.g., a future caller passing `{ version: '4.0.0' }` to update only the | ||
| // version would otherwise wipe out every other top-level field). | ||
| // 3. FALLBACK PATH: bare object + no existing destination file (fresh install). | ||
| // No comments to preserve. Use js-yaml.dump for backwards compatibility. | ||
| // | ||
| // CONTRACT NOTE: callers should not reuse the same `merged` object across multiple | ||
| // `writeConfig` calls. The Document reference inside the sentinel is mutated on | ||
| // write, so a second call would see an already-mutated Document instead of the | ||
| // originally parsed state. This is fine for current callers (refresh-installation | ||
| // calls writeConfig once per merged result) but document the constraint for | ||
| // future maintainers. | ||
| const sentinelDoc = config[MERGED_DOC_SENTINEL]; | ||
| let doc = sentinelDoc; | ||
| const isSentinelPath = !!sentinelDoc; | ||
| if (!doc && fs.existsSync(configPath)) { | ||
| // Self-heal: re-parse the existing file so its comments survive the rewrite. | ||
| try { | ||
| const existingContent = fs.readFileSync(configPath, 'utf8'); | ||
| const reparsed = YAML.parseDocument(existingContent); | ||
| if (!reparsed.errors || reparsed.errors.length === 0) { | ||
| doc = reparsed; | ||
| } | ||
| // If parse fails, fall through to the bare-object path silently — | ||
| // the caller is writing a known-good structure either way. | ||
| } catch (_err) { | ||
| // Silent — fall through to bare-object path. | ||
| } | ||
| } | ||
| let yamlContent; | ||
| if (doc) { | ||
| // Comment-preserving path: sync the merged structure into the Document via per-field | ||
| // doc.set() calls. Replacing doc.contents wholesale would blow away comments attached | ||
| // to the top-level mapping (e.g., header comments above the first key). | ||
| // Per-field updates preserve all comment metadata anchored to the document or to fields. | ||
| const merged = stripSentinel(config); | ||
| // Update existing fields and add new ones | ||
| for (const key of Object.keys(merged)) { | ||
| doc.set(key, merged[key]); | ||
| } | ||
| // Remove keys that were in the original doc but are no longer in the merged structure. | ||
| // ONLY do this on the SENTINEL path — `mergeConfig` produces a complete config so any | ||
| // missing key was intentionally removed. On the SELF-HEAL path the caller is a legacy | ||
| // bare-object caller that may not know about every on-disk field, so deleting unknown | ||
| // keys would silently destroy user data. | ||
| if (isSentinelPath && doc.contents && typeof doc.contents.items !== 'undefined') { | ||
| const mergedKeys = new Set(Object.keys(merged)); | ||
| const docKeys = doc.contents.items.map(item => String(item.key.value)); | ||
| for (const docKey of docKeys) { | ||
| if (!mergedKeys.has(docKey)) { | ||
| doc.delete(docKey); | ||
| } | ||
| } | ||
| } | ||
| yamlContent = doc.toString({ lineWidth: 0 }); | ||
| } else { | ||
| // Backwards-compat path: bare object, no existing file, no comments to preserve. | ||
| yamlContent = yaml.dump(config, { | ||
| indent: 2, | ||
| lineWidth: -1, // Don't wrap long lines | ||
| noRefs: true | ||
| }); | ||
| } | ||
| await fs.writeFile(configPath, yamlContent, 'utf8'); | ||
@@ -219,2 +333,18 @@ } | ||
| /** | ||
| * Return a plain-object copy of config with the sentinel symbol stripped, | ||
| * so doc.createNode doesn't try to serialize it. | ||
| * @param {object} config | ||
| * @returns {object} | ||
| */ | ||
| function stripSentinel(config) { | ||
| // Symbol-keyed properties are not enumerable in our case (defineProperty above), | ||
| // but as a safety net we explicitly clone only string-keyed enumerable fields. | ||
| const plain = {}; | ||
| for (const key of Object.keys(config)) { | ||
| plain[key] = config[key]; | ||
| } | ||
| return plain; | ||
| } | ||
| /** | ||
| * Add migration history entry | ||
@@ -221,0 +351,0 @@ * @param {object} config - Config object |
@@ -6,5 +6,6 @@ #!/usr/bin/env node | ||
| const yaml = require('js-yaml'); | ||
| const { getPackageVersion } = require('./utils'); | ||
| const YAML = require('yaml'); // Comment-preserving YAML library (ag-7-1: I29). Use for WRITE sites that need to preserve comments. js-yaml stays for read-only consumers. | ||
| const { getPackageVersion, assertVersion } = require('./utils'); | ||
| const configMerger = require('./config-merger'); | ||
| const { AGENTS, AGENT_FILES, AGENT_IDS, WORKFLOW_NAMES, USER_GUIDES, GYRE_AGENTS, GYRE_AGENT_FILES, GYRE_AGENT_IDS, GYRE_WORKFLOW_NAMES } = require('./agent-registry'); | ||
| const { AGENTS, AGENT_FILES, AGENT_IDS, WORKFLOW_NAMES, USER_GUIDES, GYRE_AGENTS, GYRE_AGENT_FILES, GYRE_AGENT_IDS, GYRE_WORKFLOW_NAMES, EXTRA_BME_AGENTS } = require('./agent-registry'); | ||
@@ -95,2 +96,30 @@ /** | ||
| // 2b1. Standalone bme submodule trees (e.g., _team-factory) | ||
| // Each EXTRA_BME_AGENTS entry references a submodule directory under _bmad/bme/ | ||
| // that must be copied wholesale so the agent file, workflows, lib code, and config travel together. | ||
| // Mirrors the workflow loop pattern (2-step remove-then-copy) so renamed/deleted files | ||
| // in the package don't survive in the user install as stale leftovers. | ||
| const copiedExtraSubmodules = new Set(); | ||
| if (!isSameRoot) { | ||
| for (const agent of EXTRA_BME_AGENTS) { | ||
| if (copiedExtraSubmodules.has(agent.submodule)) continue; | ||
| copiedExtraSubmodules.add(agent.submodule); | ||
| const srcDir = path.join(packageRoot, '_bmad', 'bme', agent.submodule); | ||
| const destDir = path.join(projectRoot, '_bmad', 'bme', agent.submodule); | ||
| if (fs.existsSync(srcDir)) { | ||
| // Remove existing destination first to clear stale files | ||
| // (e.g., renamed/deleted workflow steps from previous versions) | ||
| if (fs.existsSync(destDir)) { | ||
| await fs.remove(destDir); | ||
| } | ||
| await fs.copy(srcDir, destDir, { overwrite: true }); | ||
| changes.push(`Refreshed standalone bme submodule: ${agent.submodule}`); | ||
| if (verbose) console.log(` Refreshed standalone bme submodule: ${agent.submodule}`); | ||
| } | ||
| } | ||
| } else { | ||
| changes.push('Skipped standalone bme submodule copy (dev environment — files already in place)'); | ||
| if (verbose) console.log(' Skipped standalone bme submodule copy (dev environment)'); | ||
| } | ||
| // 2a. Enhance module — read config, copy directory tree, patch target agent menu | ||
@@ -120,8 +149,13 @@ const packageEnhance = path.join(packageRoot, '_bmad', 'bme', '_enhance'); | ||
| await fs.copy(packageEnhance, targetEnhance, { overwrite: true }); | ||
| // Stamp enhance config version to match package version | ||
| // Stamp enhance config version to match package version (ag-7-1: I30 + I29). | ||
| // Uses comment-preserving YAML.parseDocument so the doc comments survive. | ||
| const targetEnhanceConfig = path.join(targetEnhance, 'config.yaml'); | ||
| if (fs.existsSync(targetEnhanceConfig)) { | ||
| const ecContent = yaml.load(fs.readFileSync(targetEnhanceConfig, 'utf8')); | ||
| ecContent.version = version; | ||
| fs.writeFileSync(targetEnhanceConfig, yaml.dump(ecContent, { lineWidth: -1 }), 'utf8'); | ||
| assertVersion(version, 'enhance'); | ||
| const ecDoc = YAML.parseDocument(fs.readFileSync(targetEnhanceConfig, 'utf8')); | ||
| if (ecDoc.errors && ecDoc.errors.length > 0) { | ||
| throw new Error(`Refresh: cannot parse Enhance config.yaml: ${ecDoc.errors[0].message}`); | ||
| } | ||
| ecDoc.set('version', version); | ||
| fs.writeFileSync(targetEnhanceConfig, ecDoc.toString({ lineWidth: 0 }), 'utf8'); | ||
| } | ||
@@ -195,2 +229,57 @@ changes.push('Refreshed Enhance module: _bmad/bme/_enhance/'); | ||
| // 2c. Artifacts module — read config, copy directory tree, generate skill wrappers | ||
| // Workflow-only submodule (no agents). Workflows are STANDALONE: each gets a Claude Code | ||
| // skill wrapper but NO menu patch. The `standalone: true` flag in the workflow entry is | ||
| // the discriminator — workflows without it are NOT supported in this module today (Story 6.6). | ||
| const packageArtifacts = path.join(packageRoot, '_bmad', 'bme', '_artifacts'); | ||
| const artifactsConfigPath = path.join(packageArtifacts, 'config.yaml'); | ||
| let artifactsConfig = null; | ||
| if (fs.existsSync(artifactsConfigPath)) { | ||
| try { | ||
| artifactsConfig = yaml.load(fs.readFileSync(artifactsConfigPath, 'utf8')); | ||
| } catch (err) { | ||
| const msg = `Artifacts config.yaml parse error: ${err.message} — skipping Artifacts installation`; | ||
| changes.push(msg); | ||
| if (verbose) console.log(` ⚠ ${msg}`); | ||
| } | ||
| } else { | ||
| changes.push('Artifacts config.yaml not found — skipping Artifacts installation'); | ||
| if (verbose) console.log(' ⚠ Artifacts config.yaml not found — skipping Artifacts installation'); | ||
| } | ||
| if (artifactsConfig) { | ||
| // Copy _artifacts/ directory tree | ||
| const targetArtifacts = path.join(projectRoot, '_bmad', 'bme', '_artifacts'); | ||
| if (!isSameRoot) { | ||
| // Remove existing destination first to clear stale files | ||
| if (fs.existsSync(targetArtifacts)) { | ||
| await fs.remove(targetArtifacts); | ||
| } | ||
| await fs.copy(packageArtifacts, targetArtifacts, { overwrite: true }); | ||
| // Stamp artifacts config version to match package version (ag-7-1: I30 + I29). | ||
| // Uses comment-preserving YAML.parseDocument so the standalone:true doc comments survive. | ||
| const targetArtifactsConfig = path.join(targetArtifacts, 'config.yaml'); | ||
| if (fs.existsSync(targetArtifactsConfig)) { | ||
| assertVersion(version, 'artifacts'); | ||
| const acDoc = YAML.parseDocument(fs.readFileSync(targetArtifactsConfig, 'utf8')); | ||
| if (acDoc.errors && acDoc.errors.length > 0) { | ||
| throw new Error(`Refresh: cannot parse Artifacts config.yaml: ${acDoc.errors[0].message}`); | ||
| } | ||
| acDoc.set('version', version); | ||
| fs.writeFileSync(targetArtifactsConfig, acDoc.toString({ lineWidth: 0 }), 'utf8'); | ||
| } | ||
| changes.push('Refreshed Artifacts module: _bmad/bme/_artifacts/'); | ||
| if (verbose) console.log(' Refreshed Artifacts module: _bmad/bme/_artifacts/'); | ||
| } else { | ||
| changes.push('Skipped Artifacts copy (dev environment — files already in place)'); | ||
| if (verbose) console.log(' Skipped Artifacts copy (dev environment)'); | ||
| } | ||
| // Skill wrapper generation for each workflow happens later in section 6d, | ||
| // after skillsDir is defined (mirrors Enhance pattern: config/copy here, skill | ||
| // wrappers in section 6c after agent skills are generated). | ||
| } | ||
| // 2d. Gyre module — copy agents, workflows, contracts, config | ||
@@ -264,2 +353,3 @@ const packageGyre = path.join(packageRoot, '_bmad', 'bme', '_gyre'); | ||
| }; | ||
| assertVersion(version, 'config-merger:gyre'); // ag-7-1: defense-in-depth before mergeConfig | ||
| const gyreConfigMerged = await configMerger.mergeConfig(gyreConfigTarget, version, gyreUpdates); | ||
@@ -290,2 +380,3 @@ await configMerger.writeConfig(gyreConfigTarget, gyreConfigMerged); | ||
| assertVersion(version, 'config-merger:vortex'); // ag-7-1: defense-in-depth before mergeConfig | ||
| const merged = await configMerger.mergeConfig(configPath, version, updates); | ||
@@ -392,2 +483,30 @@ await configMerger.writeConfig(configPath, merged); | ||
| // Row builder for standalone bme agents (e.g., team-factory) — submodule path differs from team agents | ||
| function buildExtraBmeAgentRow610(a) { | ||
| const p = a.persona; | ||
| return [ | ||
| csvEscape(a.name), | ||
| csvEscape(''), | ||
| csvEscape(a.title), | ||
| csvEscape(a.icon), | ||
| csvEscape(''), | ||
| csvEscape(p.role), | ||
| csvEscape(p.identity), | ||
| csvEscape(p.communication_style), | ||
| csvEscape(p.expertise), | ||
| csvEscape('bme'), | ||
| csvEscape(`_bmad/bme/${a.submodule}/agents/${a.id}.md`), | ||
| csvEscape(`bmad-agent-bme-${a.id}`), | ||
| ].join(','); | ||
| } | ||
| function buildExtraBmeAgentRowLegacy(a) { | ||
| const p = a.persona; | ||
| return [ | ||
| a.id, a.name, a.title, a.icon, | ||
| p.role, p.identity, p.communication_style, p.expertise, | ||
| 'bme', `_bmad/bme/${a.submodule}/agents/${a.id}.md`, | ||
| ].map(csvEscape).join(','); | ||
| } | ||
| let bmeRows; | ||
@@ -398,2 +517,3 @@ if (isV610) { | ||
| ...GYRE_AGENTS.map(a => buildAgentRow610(a, '_gyre')), | ||
| ...EXTRA_BME_AGENTS.map(buildExtraBmeAgentRow610), | ||
| ]; | ||
@@ -404,2 +524,3 @@ } else { | ||
| ...GYRE_AGENTS.map(a => buildAgentRowLegacy(a, '_gyre')), | ||
| ...EXTRA_BME_AGENTS.map(buildExtraBmeAgentRowLegacy), | ||
| ]; | ||
@@ -458,2 +579,3 @@ } | ||
| ...GYRE_AGENTS.map(a => `bmad-agent-bme-${a.id}`), | ||
| ...EXTRA_BME_AGENTS.map(a => `bmad-agent-bme-${a.id}`), | ||
| ]); | ||
@@ -520,2 +642,27 @@ if (fs.existsSync(skillsDir)) { | ||
| // 6b1. Generate .claude/skills/ for standalone bme agents (e.g., team-factory) | ||
| for (const agent of EXTRA_BME_AGENTS) { | ||
| const skillDir = path.join(skillsDir, `bmad-agent-bme-${agent.id}`); | ||
| await fs.ensureDir(skillDir); | ||
| const content = `--- | ||
| name: bmad-agent-bme-${agent.id} | ||
| description: ${agent.id} agent | ||
| --- | ||
| You must fully embody this agent's persona and follow all activation instructions exactly as specified. NEVER break character until given an exit command. | ||
| <agent-activation CRITICAL="TRUE"> | ||
| 1. LOAD the FULL agent file from {project-root}/_bmad/bme/${agent.submodule}/agents/${agent.id}.md | ||
| 2. READ its entire contents - this contains the complete agent persona, menu, and instructions | ||
| 3. FOLLOW every step in the <activation> section precisely | ||
| 4. DISPLAY the welcome/greeting as instructed | ||
| 5. PRESENT the numbered menu | ||
| 6. WAIT for user input before proceeding | ||
| </agent-activation> | ||
| `; | ||
| await fs.writeFile(path.join(skillDir, 'SKILL.md'), content, 'utf8'); | ||
| changes.push(`Refreshed skill: bmad-agent-bme-${agent.id}/SKILL.md`); | ||
| if (verbose) console.log(` Refreshed skill: bmad-agent-bme-${agent.id}/SKILL.md`); | ||
| } | ||
| // 6c. Copy Enhance workflow skill wrappers and register in manifests | ||
@@ -554,3 +701,3 @@ if (enhanceConfig && !isSameRoot) { | ||
| if (!skCsv.includes(`"${canonicalId}"`)) { | ||
| const skRow = `\n"${canonicalId}","${canonicalId}","Manage RICE initiatives backlog — triage review findings, rescore existing items, or bootstrap new backlogs.","bme","_bmad/bme/_enhance/workflows/${workflow.name}/SKILL.md","true"`; | ||
| const skRow = `\n"${canonicalId}","${canonicalId}","Manage RICE initiatives backlog — triage review findings, rescore existing items, or bootstrap new backlogs.","bme","_bmad/bme/_enhance/workflows/${workflow.name}/SKILL.md","true",,,`; | ||
| fs.appendFileSync(skManifestPath, skRow, 'utf8'); | ||
@@ -569,2 +716,76 @@ changes.push(`Added ${canonicalId} to skill-manifest.csv`); | ||
| // 6d. Copy Artifacts workflow skill wrappers (Story 6.6) | ||
| // Each standalone:true workflow gets a skill wrapper at .claude/skills/{workflow.name}/SKILL.md. | ||
| // workflow.name already carries the bmad- prefix, so we use it verbatim (unlike Enhance which | ||
| // synthesizes bmad-enhance-${workflow.name}). The remove-then-copy pattern clears any leftover | ||
| // files from prior installs (e.g., the obsolete bmad-portfolio-status/workflow.md thin wrapper). | ||
| if (artifactsConfig && !isSameRoot) { | ||
| for (const workflow of artifactsConfig.workflows || []) { | ||
| if (workflow.standalone !== true) { | ||
| const msg = `Artifacts: workflow ${workflow.name} has no standalone:true flag — only standalone workflows are supported, skipping`; | ||
| changes.push(msg); | ||
| if (verbose) console.log(` ⚠ ${msg}`); | ||
| continue; | ||
| } | ||
| const destSkillDir = path.join(skillsDir, workflow.name); | ||
| // Remove the destination directory first to clear leftover files from prior installs | ||
| if (fs.existsSync(destSkillDir)) { | ||
| await fs.remove(destSkillDir); | ||
| } | ||
| await fs.ensureDir(destSkillDir); | ||
| // Copy source SKILL.md from the package (the SKILL.md uses an absolute {project-root} | ||
| // path to load workflow.md, so workflow.md does NOT need to be co-located). | ||
| const sourceSkillPath = path.join(packageRoot, '_bmad', 'bme', '_artifacts', 'workflows', workflow.name, 'SKILL.md'); | ||
| const targetSkillPath = path.join(destSkillDir, 'SKILL.md'); | ||
| if (fs.existsSync(sourceSkillPath)) { | ||
| await fs.copy(sourceSkillPath, targetSkillPath, { overwrite: true }); | ||
| changes.push(`Generated skill wrapper: ${workflow.name}`); | ||
| if (verbose) console.log(` Generated skill wrapper: ${workflow.name}`); | ||
| } else { | ||
| const msg = `Artifacts: source SKILL.md not found for ${workflow.name} at ${sourceSkillPath}`; | ||
| changes.push(msg); | ||
| if (verbose) console.log(` ⚠ ${msg}`); | ||
| } | ||
| } | ||
| } else if (artifactsConfig && isSameRoot) { | ||
| changes.push('Skipped Artifacts skill wrapper generation (dev environment — source files unchanged)'); | ||
| if (verbose) console.log(' Skipped Artifacts skill wrapper generation (dev environment)'); | ||
| } | ||
| // 6e. Orphan workflow-wrapper cleanup (Story 7.4, I32) | ||
| // Removes stale .claude/skills/ directories for workflow wrappers that are no longer | ||
| // declared in the module configs. Uses a two-strategy matching approach: | ||
| // Strategy 1 (Enhance): any bmad-enhance-* dir not in the current union → orphan | ||
| // Strategy 2 (Artifacts): any dir whose name exactly matches a known Artifacts | ||
| // workflow name but is not in the current union → orphan | ||
| // All other directories (agent wrappers, upstream BMAD skills, third-party) are ignored. | ||
| if (!isSameRoot) { | ||
| const currentWorkflowWrappers = new Set(); | ||
| // Enhance wrappers: bmad-enhance-${workflow.name} | ||
| if (enhanceConfig && Array.isArray(enhanceConfig.workflows)) { | ||
| for (const wf of enhanceConfig.workflows) { | ||
| if (wf && wf.name) currentWorkflowWrappers.add(`bmad-enhance-${wf.name}`); | ||
| } | ||
| } | ||
| // Artifacts wrappers: workflow.name verbatim (only standalone:true are installed, | ||
| // but we track ALL names so a removed standalone workflow is still recognized as an orphan) | ||
| const knownArtifactsNames = new Set(); | ||
| if (artifactsConfig && Array.isArray(artifactsConfig.workflows)) { | ||
| for (const wf of artifactsConfig.workflows) { | ||
| if (wf && wf.name) { | ||
| knownArtifactsNames.add(wf.name); | ||
| if (wf.standalone === true) currentWorkflowWrappers.add(wf.name); | ||
| } | ||
| } | ||
| } | ||
| const orphanChanges = cleanupOrphanWorkflowWrappers(skillsDir, currentWorkflowWrappers, knownArtifactsNames, { verbose }); | ||
| changes.push(...orphanChanges); | ||
| } else { | ||
| changes.push('Skipped orphan workflow-wrapper cleanup (dev environment)'); | ||
| if (verbose) console.log(' Skipped orphan workflow-wrapper cleanup (dev environment)'); | ||
| } | ||
| // 7. Generate agent customize files (only if they don't already exist) | ||
@@ -615,2 +836,66 @@ const customizeDir = path.join(projectRoot, '_bmad', '_config', 'agents'); | ||
| module.exports = { refreshInstallation }; | ||
| /** | ||
| * Remove orphan workflow-wrapper directories from .claude/skills/. | ||
| * | ||
| * Two-strategy matching (Story 7.4, I32): | ||
| * Strategy 1: Enhance prefix — any dir starting with `bmad-enhance-` that is | ||
| * not in `currentWrappers` is an orphan. | ||
| * Strategy 2: Artifacts exact-name — any dir whose name is in `knownArtifactsNames` | ||
| * but not in `currentWrappers` is an orphan. | ||
| * Everything else (agent wrappers, upstream BMAD skills, third-party) is ignored. | ||
| * | ||
| * @param {string} skillsDir - Absolute path to .claude/skills/ | ||
| * @param {Set<string>} currentWrappers - Union of live workflow wrapper names | ||
| * @param {Set<string>} knownArtifactsNames - ALL Artifacts workflow names (including non-standalone) | ||
| * @param {object} [options] | ||
| * @param {boolean} [options.verbose] - Log each action | ||
| * @returns {Array<string>} Changes array entries for removed orphans | ||
| */ | ||
| function cleanupOrphanWorkflowWrappers(skillsDir, currentWrappers, knownArtifactsNames, options = {}) { | ||
| // Deliberately synchronous (fs.removeSync / fs.readdirSync) — the function returns | ||
| // Array<string>, not a Promise. The sync pattern keeps the contract simple for both | ||
| // the caller (section 6e spreads the result into changes[]) and the test file (which | ||
| // imports the function directly without async scaffolding). The existing agent | ||
| // stale-skill sweep at section 6 uses async fs.remove because it runs inline in the | ||
| // async refreshInstallation body; this function is extracted to be testable standalone. | ||
| const { verbose = false } = options; | ||
| const changes = []; | ||
| if (!fs.existsSync(skillsDir)) return changes; | ||
| const entries = fs.readdirSync(skillsDir, { withFileTypes: true }); | ||
| for (const entry of entries) { | ||
| if (!entry.isDirectory()) continue; | ||
| const name = entry.name; | ||
| // Skip agent wrappers (handled by existing stale-skill sweep) | ||
| if (name.startsWith('bmad-agent-bme-')) continue; | ||
| // Strategy 1: Enhance prefix (unambiguous — no upstream module uses bmad-enhance-) | ||
| if (name.startsWith('bmad-enhance-')) { | ||
| if (!currentWrappers.has(name)) { | ||
| fs.removeSync(path.join(skillsDir, name)); | ||
| changes.push(`Removed orphan skill wrapper: ${name}`); | ||
| if (verbose) console.log(` Removed orphan skill wrapper: ${name}`); | ||
| } | ||
| continue; | ||
| } | ||
| // Strategy 2: Artifacts exact-name match | ||
| if (knownArtifactsNames.has(name)) { | ||
| if (!currentWrappers.has(name)) { | ||
| fs.removeSync(path.join(skillsDir, name)); | ||
| changes.push(`Removed orphan skill wrapper: ${name}`); | ||
| if (verbose) console.log(` Removed orphan skill wrapper: ${name}`); | ||
| } | ||
| continue; | ||
| } | ||
| // Everything else: not a workflow wrapper we own — leave alone | ||
| } | ||
| return changes; | ||
| } | ||
| module.exports = { refreshInstallation, cleanupOrphanWorkflowWrappers }; |
@@ -91,2 +91,27 @@ #!/usr/bin/env node | ||
| /** | ||
| * Assert that a version string is valid before stamping a config file. | ||
| * Throws a clear error if version is undefined, null, or empty. | ||
| * Used by refresh-installation.js, config-merger.js, and any future config writer | ||
| * that stamps a version field — closes I30 (ag-7-1). | ||
| * | ||
| * @param {string} version - The version string to validate | ||
| * @param {string} callSite - Identifier for the call site (e.g., 'enhance', 'artifacts', 'config-merger') | ||
| * @throws {Error} if version is not a non-empty string | ||
| */ | ||
| function assertVersion(version, callSite) { | ||
| // Reject all non-string types (numeric 0, boolean false, etc.) — version | ||
| // must be a non-empty string. Closes Blind Hunter finding #9 (ag-7-1 review). | ||
| if (typeof version !== 'string' || version === '') { | ||
| let displayed; | ||
| if (version === null) displayed = 'null'; | ||
| else if (version === undefined) displayed = 'undefined'; | ||
| else if (version === '') displayed = "''"; | ||
| else displayed = `${typeof version} (${String(version)})`; | ||
| throw new Error( | ||
| `Refresh: cannot stamp config — getPackageVersion() returned ${displayed}; check package.json (call site: ${callSite})` | ||
| ); | ||
| } | ||
| } | ||
| module.exports = { | ||
@@ -96,3 +121,4 @@ getPackageVersion, | ||
| countUserDataFiles, | ||
| findProjectRoot | ||
| findProjectRoot, | ||
| assertVersion | ||
| }; |
@@ -8,3 +8,3 @@ #!/usr/bin/env node | ||
| const { countUserDataFiles } = require('./utils'); | ||
| const { AGENT_FILES, AGENT_IDS, WORKFLOW_NAMES, WAVE3_WORKFLOW_NAMES } = require('./agent-registry'); | ||
| const { AGENT_FILES, AGENT_IDS, WORKFLOW_NAMES, WAVE3_WORKFLOW_NAMES, EXTRA_BME_AGENTS, EXTRA_BME_AGENT_IDS } = require('./agent-registry'); | ||
@@ -51,2 +51,5 @@ /** | ||
| // 9. Artifacts module validation (optional — passes if not installed) | ||
| checks.push(await validateArtifactsModule(projectRoot)); | ||
| const allPassed = checks.every(c => c.passed); | ||
@@ -207,10 +210,21 @@ | ||
| // Check for all Convoke agents | ||
| // Check for all Convoke agents (Vortex/Gyre IDs and standalone bme agents) | ||
| const missingFromManifest = AGENT_IDS.filter(id => !manifestContent.includes(id)); | ||
| const missingExtras = EXTRA_BME_AGENT_IDS.filter(id => !manifestContent.includes(`bmad-agent-bme-${id}`)); | ||
| if (missingFromManifest.length > 0) { | ||
| check.error = `Agent manifest missing: ${missingFromManifest.join(', ')}`; | ||
| const allMissing = [...missingFromManifest, ...missingExtras]; | ||
| if (allMissing.length > 0) { | ||
| check.error = `Agent manifest missing: ${allMissing.join(', ')}`; | ||
| return check; | ||
| } | ||
| // Confirm standalone bme agent files exist on disk | ||
| const missingExtraFiles = EXTRA_BME_AGENTS | ||
| .filter(a => !fs.existsSync(path.join(projectRoot, '_bmad', 'bme', a.submodule, 'agents', `${a.id}.md`))) | ||
| .map(a => a.id); | ||
| if (missingExtraFiles.length > 0) { | ||
| check.error = `Standalone bme agent files missing: ${missingExtraFiles.join(', ')}`; | ||
| return check; | ||
| } | ||
| check.passed = true; | ||
@@ -475,2 +489,97 @@ } catch (error) { | ||
| /** | ||
| * Validate Artifacts module installation (optional — passes if not installed) | ||
| * Performs 5-point verification: directory, config, workflows array, per-workflow entry, per-workflow skill wrapper | ||
| * @param {string} projectRoot - Absolute path to project root | ||
| * @returns {Promise<object>} Validation check result | ||
| */ | ||
| async function validateArtifactsModule(projectRoot) { | ||
| const check = { | ||
| name: 'Artifacts module', | ||
| passed: false, | ||
| error: null | ||
| }; | ||
| try { | ||
| const artifactsDir = path.join(projectRoot, '_bmad/bme/_artifacts'); | ||
| // Check 1: Directory exists — if not, Artifacts is simply not installed (optional) | ||
| if (!fs.existsSync(artifactsDir)) { | ||
| check.passed = true; | ||
| check.info = 'not installed'; | ||
| return check; | ||
| } | ||
| const failures = []; | ||
| // Check 2: Config parse — bail early if config is unreadable, since later checks | ||
| // depend on a parsed workflows array. | ||
| const configPath = path.join(artifactsDir, 'config.yaml'); | ||
| if (!fs.existsSync(configPath)) { | ||
| check.error = 'Artifacts: config.yaml not found'; | ||
| return check; | ||
| } | ||
| let config = null; | ||
| try { | ||
| config = yaml.load(fs.readFileSync(configPath, 'utf8')); | ||
| } catch (err) { | ||
| check.error = `Artifacts: config.yaml parse error: ${err.message}`; | ||
| return check; | ||
| } | ||
| if (!config || typeof config !== 'object') { | ||
| check.error = 'Artifacts: config.yaml is empty or invalid'; | ||
| return check; | ||
| } | ||
| // Check 3: Workflows array non-empty | ||
| if (!Array.isArray(config.workflows) || config.workflows.length === 0) { | ||
| check.error = 'Artifacts: config.yaml has no workflows array'; | ||
| return check; | ||
| } | ||
| // Checks 4 & 5: Per-workflow entry point and skill wrapper. | ||
| // Aggregate failures across all workflows so a single doctor run reports every | ||
| // problem at once (mirrors validateEnhanceModule). | ||
| // Non-standalone workflows are skipped from wrapper/entry checks because | ||
| // refresh-installation.js section 6d does NOT install them — validating their | ||
| // wrapper would be a contract mismatch with the refresh logic. | ||
| for (const wf of config.workflows) { | ||
| if (!wf || !wf.name || !wf.entry) { | ||
| failures.push('workflow entry missing name or entry field'); | ||
| continue; | ||
| } | ||
| if (wf.standalone !== true) { | ||
| // Refresh skips non-standalone workflows; nothing to validate. | ||
| continue; | ||
| } | ||
| // Check 4: Workflow entry point file exists | ||
| const entryPath = path.join(artifactsDir, wf.entry); | ||
| if (!fs.existsSync(entryPath)) { | ||
| failures.push(`workflow entry missing for ${wf.name}: ${wf.entry}`); | ||
| } | ||
| // Check 5: Skill wrapper exists at .claude/skills/{workflow.name}/SKILL.md | ||
| // (workflow.name already carries the bmad- prefix; do NOT synthesize bmad-${wf.name}) | ||
| const skillWrapperPath = path.join(projectRoot, '.claude', 'skills', wf.name, 'SKILL.md'); | ||
| if (!fs.existsSync(skillWrapperPath)) { | ||
| failures.push(`skill wrapper missing for ${wf.name}`); | ||
| } | ||
| } | ||
| if (failures.length > 0) { | ||
| check.error = `Artifacts: ${failures.join('; ')}`; | ||
| } else { | ||
| check.passed = true; | ||
| } | ||
| } catch (error) { | ||
| check.error = error.message; | ||
| } | ||
| return check; | ||
| } | ||
| /** | ||
| * Validate a SKILL.md file has required frontmatter fields | ||
@@ -640,2 +749,3 @@ * @param {string} skillMdPath - Absolute path to SKILL.md file | ||
| validateEnhanceModule, | ||
| validateArtifactsModule, | ||
| validateSkillMd, | ||
@@ -642,0 +752,0 @@ validateStepFiles, |
Sorry, the diff of this file is too big to display
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
2155267
30.27%400
18.34%17050
116.78%5
25%17
325%5
25%+ Added
+ Added