🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

convoke-agents

Package Overview
Dependencies
Maintainers
1
Versions
16
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

convoke-agents - npm Package Compare versions

Comparing version
3.0.4
to
3.1.0
+506
_bmad/bme/_gyre/guides/GYRE-TEAM-GUIDE.md
# The Gyre Deep Dive — Production Readiness Discovery
**Team:** Gyre Pattern (4 Agents, 7 Workflows, 4 Handoff Contracts)
**Module:** Convoke (bme)
**Version:** 1.0.0
**Last Updated:** 2026-04-05
---
## What This Guide Covers
The per-agent user guides tell you *how to use Scout, Atlas, Lens, and Coach individually*. This guide tells you *how the team works together* — the pipeline logic, what flows between agents, what good artifacts look like, how modes change the experience, and where teams get stuck.
If you've read the individual user guides, you're ready for this.
---
## The Gyre at a Glance
Gyre is a production readiness discovery system. It analyzes your codebase to find **what's missing before you ship** — not bugs, not code quality, but absence. Missing health checks. Missing rollback strategies. Missing observability that would have caught last week's outage.
```
Scout 🔎 Atlas 📐 Lens 🔬 Coach 🏋️
Detect → Model → Analyze → Review
(stack) (capabilities) (absences) (feedback)
▲ │
└──────── GC4 feedback ────────┘
```
**The big idea:** You can't find what's missing unless you first know what *should* exist. Scout identifies your stack. Atlas generates a capabilities model tailored to that stack. Lens compares the model against your codebase and reports absences. Coach helps you review, customize, and improve the model over time.
---
## How to Read This Guide
This guide is organized around five layers:
1. **The Pipeline** — How artifacts flow from one agent to the next
2. **Walkthrough: A Complete Gyre Analysis** — Step-by-step through a realistic scenario
3. **Artifact Examples** — What good output looks like at each stage
4. **Scenarios & Entry Points** — Where to start depending on your situation
5. **Anti-Patterns** — Where teams go wrong and how to avoid it
---
## Part 1: The Pipeline
### Forward Flow (GC1-GC3)
The core pipeline moves artifacts forward through three handoff contracts:
| Contract | From | To | What Flows | In Plain English |
|----------|------|-----|-----------|------------------|
| **GC1** | Scout 🔎 | Atlas 📐, Lens 🔬 | Stack Profile — technology categories, archetype, confidence | "Here's what your project is built with" |
| **GC2** | Atlas 📐 | Lens 🔬, Coach 🏋️ | Capabilities Manifest — 20+ capabilities relevant to this stack | "Here's what a project like yours should have" |
| **GC3** | Lens 🔬 | Coach 🏋️ | Findings Report — absences with severity, confidence, evidence | "Here's what's missing, ranked by how much it matters" |
### Feedback Loop (GC4)
| Contract | From | To | What Flows | In Plain English |
|----------|------|-----|-----------|------------------|
| **GC4** | Coach 🏋️ | Atlas 📐 | Amendment flags + missed-gap feedback | "The user corrected the model — respect these changes next time" |
### The Privacy Boundary
A critical design principle: **artifacts contain categories, not contents.**
- GC1 (Stack Profile) records "Node.js, Express, Kubernetes" — never file paths, version numbers, or dependency names
- GC2 (Capabilities Manifest) records what *should* exist — never what *does* exist
- GC3 (Findings Report) records what's *absent* with evidence summaries — never source code
This means `.gyre/` artifacts are safe to commit to your repo. The model is shareable.
---
## Part 2: Walkthrough — A Complete Gyre Analysis
Let's walk through analyzing a Node.js Express API deployed on AWS EKS via GitHub Actions. Your team is preparing for the first production launch and wants to know what you're missing.
### Stage 1: Scout — Detect the Stack
**What you do:** Invoke Scout and run **[FA] Full Analysis** (recommended for first time) or **[DS] Detect Stack** (standalone detection).
Scout scans your project filesystem looking for technology indicators:
| What Scout Looks For | Files Examined |
|---------------------|---------------|
| Primary language/framework | `package.json`, `go.mod`, `requirements.txt`, `Cargo.toml`, `pom.xml` |
| Container orchestration | `Dockerfile`, `docker-compose.yaml`, Kubernetes manifests, ECS task definitions |
| CI/CD platform | `.github/workflows/`, `.gitlab-ci.yml`, `Jenkinsfile` |
| Observability tooling | OpenTelemetry configs, Prometheus configs, Datadog agent configs |
| Cloud provider | Terraform files, CloudFormation templates, Pulumi programs |
| Communication protocols | gRPC protos, REST controllers, message queue configs |
**Guard questions:** After detection, Scout asks up to 3 targeted questions to resolve ambiguities. These aren't a generic questionnaire — they're derived from what Scout *couldn't* determine from the filesystem alone.
Example guard questions for our scenario:
- "I found both a Dockerfile and Kubernetes manifests. Is this container-based deployment or do you also use serverless?" → *container-based*
- "I see Express routes but no gRPC protos. Is HTTP/REST your primary communication protocol?" → *yes*
**Monorepo handling:** If Scout detects multiple service boundaries (e.g., `/api/`, `/worker/`, `/frontend/`), it asks you to select which service to analyze. Each service gets its own `.gyre/` directory.
**What you get (GC1):**
```yaml
stack_profile:
primary_language: "Node.js"
primary_framework: "Express"
secondary_stacks: []
container_orchestration: "Kubernetes"
ci_cd_platform: "GitHub Actions"
observability_tooling: ["OpenTelemetry", "Prometheus"]
cloud_provider: "AWS"
communication_protocol: "HTTP/REST"
guard_answers:
deployment_model: "container-based"
detection_confidence: "high"
detection_summary: "Node.js Express web service deployed on AWS EKS
via GitHub Actions. Instrumented with OpenTelemetry and Prometheus.
REST API."
```
### Stage 2: Atlas — Generate the Capabilities Model
**What you do:** In Full Analysis mode, Atlas runs automatically after Scout. Standalone: invoke Atlas and run **[GM] Generate Model**.
Atlas loads the GC1 Stack Profile and generates a capabilities manifest — a list of everything a production-grade project *like yours* should have. Atlas draws from:
- **Industry standards:** DORA metrics, OpenTelemetry, Google PRR checklist
- **Web search:** Current best practices for your specific stack combination
- **Contextual reasoning:** Inferences from your stack profile (e.g., "gRPC + Kubernetes = gRPC health checking protocol is relevant")
**What matters here:** Atlas generates capabilities across four domains:
| Domain | What It Covers | Typical Count |
|--------|---------------|:------------:|
| **Observability** | Logging, tracing, metrics, health checks, alerting | 6-10 |
| **Deployment** | CI/CD, containers, orchestration, rollback, IaC | 5-8 |
| **Reliability** | Graceful shutdown, circuit breakers, rate limiting, fault tolerance | 4-6 |
| **Security** | Secrets management, vulnerability scanning, network policies, auth | 3-5 |
Each capability has a `source` tag explaining where it came from:
| Source | Meaning | Example |
|--------|---------|---------|
| `standard` | From a named industry framework | "Kubernetes liveness probe" from Google PRR |
| `practice` | Common industry practice | "Multi-stage Docker builds" |
| `reasoning` | Atlas inferred this from your stack | "gRPC health checking protocol" inferred from gRPC + Kubernetes |
**The 20-capability threshold:** Atlas aims for 20+ capabilities. If it generates fewer, it sets `limited_coverage: true` in the manifest and warns you. This typically happens with unusual or niche stacks where fewer established practices exist.
**What you get (GC2):** A capabilities manifest at `.gyre/capabilities.yaml` with 20-30 capabilities, each explaining what it is, why it matters for *your specific stack*, and where the recommendation came from.
### Stage 3: Lens — Find What's Missing
**What you do:** In Full Analysis, Lens runs automatically. Standalone: invoke Lens and run **[AG] Analyze Gaps**.
Lens loads the GC2 Capabilities Manifest and systematically checks each capability against your actual codebase. For each capability, Lens:
1. **Searches for evidence** using Glob (file patterns), Grep (content patterns), and Read (config inspection)
2. **Classifies the result:** present (evidence found), absent (no evidence), or partial (config exists but incomplete)
3. **Tags each finding** with source, confidence, and severity
**The two finding sources:**
| Source | Meaning | Example |
|--------|---------|---------|
| `static-analysis` | File-level evidence (found or absent) | "Grep for 'healthz', 'liveness': no matches" |
| `contextual-model` | Inferred from the capabilities manifest | "No explicit rollback strategy detected" |
**Severity levels:**
| Severity | Meaning | Example |
|----------|---------|---------|
| `blocker` | Must fix before production | No Kubernetes liveness probe on EKS |
| `recommended` | Should fix, significant risk reduction | No rollback mechanism in deployment config |
| `nice-to-have` | Good practice, low risk if absent | Custom Prometheus metrics dashboard |
**Cross-domain correlation — the compound findings:**
This is where Gyre gets interesting. After analyzing each domain independently, Lens looks for *compound patterns* — two absences from different domains that amplify each other's risk.
Example: "No health checks" (observability) + "No rollback strategy" (deployment) = **unrecoverable deployment failure risk**. Neither finding alone is catastrophic, but together they mean a bad deployment goes undetected AND can't be reversed.
**Sanity check:** Lens runs a sanity check on its own findings. If more than 80% of capabilities are flagged as absent, something is probably wrong with the model (not the project). Lens warns you and suggests reviewing the capabilities manifest with Coach.
**What you get (GC3):** A findings report at `.gyre/findings.yaml` with:
- Individual findings sorted by severity (blockers first)
- Compound findings that reveal cross-domain risk amplification
- Evidence summaries explaining what was searched and what was found/absent
- A summary with counts: X blockers, Y recommended, Z nice-to-have
### Stage 4: Coach — Review and Customize
**What you do:** In Full Analysis, Coach runs as the final stage. Standalone: invoke Coach and run **[RF] Review Findings** or **[RM] Review Model**.
Coach walks you through two review activities:
**Findings Review:** Coach presents each finding severity-first (blockers, then recommended, then nice-to-have) and asks for your assessment. You know your stack better than any model — a "blocker" might be handled by a system Coach can't see, or a "nice-to-have" might be critical for your compliance requirements.
**Model Review:** Coach walks you through each capability and asks: keep, remove, edit, or skip? Your amendments are written directly to `capabilities.yaml` with flags:
| Action | What Happens | Persistence |
|--------|-------------|-------------|
| **Keep** | Capability stays as-is | Default state |
| **Remove** | `removed: true` flag added | Persists across regeneration — Atlas will never re-add it |
| **Edit** | `amended: true` flag + original values preserved | Persists across regeneration — Atlas preserves your edits |
| **Add** | New capability with `source: "user-added"` | Persists across regeneration |
**Missed-gap feedback:** After review, Coach asks: "Did Gyre miss anything you know about?" Your answers go into `.gyre/feedback.yaml` and inform Atlas on the next regeneration.
**The commit workflow:** Coach explains that committing `.gyre/` artifacts shares the model with your team. Amendments and feedback improve the model for everyone — it's a collaborative knowledge base.
---
## Part 3: Artifact Examples
### Stack Profile (Scout's GC1 Output)
```yaml
---
contract: GC1
type: artifact
source_agent: scout
source_workflow: stack-detection
target_agents: [atlas, lens]
input_artifacts: []
created: 2026-04-05
---
stack_profile:
primary_language: "Node.js"
primary_framework: "Express"
secondary_stacks: []
container_orchestration: "Kubernetes"
ci_cd_platform: "GitHub Actions"
observability_tooling: ["OpenTelemetry", "Prometheus"]
cloud_provider: "AWS"
communication_protocol: "HTTP/REST"
guard_answers:
deployment_model: "container-based"
detection_confidence: "high"
detection_summary: "Node.js Express web service deployed on AWS EKS
via GitHub Actions. Instrumented with OpenTelemetry and Prometheus.
REST API."
```
Notice what's NOT here: no file paths, no version numbers, no dependency names. Just technology categories.
### Capabilities Manifest (Atlas's GC2 Output — Excerpt)
```yaml
gyre_manifest:
version: "1.0"
generated_at: "2026-04-05T14:30:00Z"
stack_summary: "Node.js Express web service on AWS EKS via GitHub Actions"
capability_count: 24
limited_coverage: false
capabilities:
- id: "structured-logging"
category: "observability"
name: "Structured JSON Logging"
description: "Application logs in structured JSON format with
correlation IDs for request tracing. Essential for EKS workloads
where CloudWatch Logs Insights is used for analysis."
source: "standard"
relevance: "Node.js services on EKS need structured logs for
CloudWatch Logs Insights queries and cross-service correlation."
amended: false
removed: false
- id: "health-check-liveness"
category: "observability"
name: "Kubernetes Liveness Probe"
description: "HTTP endpoint (typically /healthz) that Kubernetes
uses to detect stuck containers and restart them."
source: "standard"
relevance: "EKS requires liveness probes to auto-heal unresponsive
pods. Express apps need a lightweight /healthz endpoint."
amended: false
removed: false
- id: "graceful-shutdown"
category: "reliability"
name: "Graceful Shutdown Handler"
description: "Process handles SIGTERM by draining connections,
completing in-flight requests, and closing DB pools before exit."
source: "practice"
relevance: "Kubernetes sends SIGTERM before killing pods. Without
graceful shutdown, active requests are dropped mid-response."
amended: false
removed: false
provenance:
standards_referenced: ["DORA", "OpenTelemetry", "Google PRR"]
web_search_performed: true
web_search_date: "2026-04-05"
```
Each capability explains *why it matters for your specific stack* — not just what it is in general.
### Findings Report (Lens's GC3 Output — Excerpt)
```yaml
gyre_findings:
version: "1.0"
analyzed_at: "2026-04-05T15:00:00Z"
mode: "crisis"
stack_summary: "Node.js Express web service on AWS EKS via GitHub Actions"
summary:
blockers: 2
recommended: 5
nice_to_have: 3
total: 10
novelty_ratio: "7 of 10 contextual"
findings:
- id: "OBS-001"
domain: "observability"
severity: "blocker"
source: "static-analysis"
confidence: "high"
capability_ref: "health-check-liveness"
description: "No Kubernetes liveness probe detected. EKS pods will
not be auto-healed when unresponsive."
evidence_summary: "Glob for **/health*, **/liveness*: no files found.
Grep for 'healthz', 'liveness': no matches in source files. No
HEALTHCHECK in Dockerfile."
severity_rationale: "EKS requires liveness probes for auto-healing.
Without them, stuck pods persist until manual intervention."
- id: "DEP-003"
domain: "deployment"
severity: "recommended"
source: "static-analysis"
confidence: "medium"
capability_ref: "rollback-strategy"
description: "No rollback mechanism detected in deployment config."
evidence_summary: "Grep for 'rollback', 'revision', 'undo' in k8s/
and .github/workflows/: no matches."
severity_rationale: "Rolling updates without explicit rollback
increase recovery time from failed deployments."
compound_findings:
- id: "COMPOUND-001"
domain: "cross-domain"
severity: "blocker"
source: "contextual-model"
confidence: "high"
capability_ref: ["health-check-liveness", "rollback-strategy"]
description: "No health checks combined with no rollback creates
unrecoverable deployment failure risk."
evidence_summary: "Combines OBS-001 (no liveness probe) with DEP-003
(no rollback). Failed deployments cannot be detected by K8s and
cannot be reversed."
related_findings: ["OBS-001", "DEP-003"]
combined_impact: "Without liveness probes, K8s cannot detect that a
new deployment is unhealthy. Without rollback, the failed deployment
persists. Together: a bad deploy goes undetected and unrecoverable
until manual intervention."
sanity_check:
passed: true
warnings: []
```
The `novelty_ratio` ("7 of 10 contextual") tells you how many findings come from the contextual model vs. generic static analysis. A high contextual ratio means Gyre is earning its keep — it's not just running a linter.
---
## Part 4: Scenarios & Entry Points
### "First time running Gyre on this project"
**Start with:** Scout 🔎 → **[FA] Full Analysis**
**What happens:** The full pipeline runs end-to-end (Scout → Atlas → Lens → Coach). Gyre detects *Crisis mode* (no `.gyre/` directory exists) and runs everything from scratch.
This is the recommended starting point. You get a complete picture in one session.
### "I ran Gyre before and want to check progress"
**Start with:** Scout 🔎 → **[FA] Full Analysis** or Lens 🔬 → **[AG] Analyze Gaps**
**What happens:** Gyre detects *Anticipation mode* (`.gyre/` exists). Model generation is skipped (the capabilities manifest serves as cache). Only gap analysis and review run — faster and focused on what changed.
### "I want to regenerate the model from scratch"
**Start with:** Atlas 📐 → **[GM] Generate Model** in *Regeneration mode*
**What happens:** Fresh model generation replaces the cached manifest. Atlas still respects your GC4 amendments — removed capabilities stay removed, edited capabilities persist.
### "I changed my stack significantly"
**Start with:** Scout 🔎 → **[DS] Detect Stack**
**What happens:** Scout re-scans and produces a new GC1 Stack Profile. Then run Atlas → Lens → Coach to get updated findings.
Use this after adding Kubernetes, switching CI providers, adopting a new observability stack, or any major infrastructure change.
### "I just want to review and customize the model"
**Start with:** Coach 🏋️ → **[RM] Review Model**
**What happens:** Coach loads the existing capabilities manifest and walks you through it. Keep, remove, edit, or add capabilities. Your changes persist across future Gyre runs.
### "I want to see what changed since last analysis"
**Start with:** Lens 🔬 → **[DR] Delta Report**
**What happens:** Lens compares the current findings against `.gyre/findings.previous.yaml` and shows you what's new, what's resolved, and what changed severity.
---
## Part 5: Anti-Patterns
### 1. "Running Gyre Without Reading the Findings"
**The mistake:** Running Full Analysis, seeing "2 blockers, 5 recommended, 3 nice-to-have," and moving on without reviewing with Coach.
**Why it fails:** Gyre's model is a hypothesis about what your stack needs. Without Coach's review, you're trusting the model blindly. The model might flag something your team intentionally omitted, or miss something only you know about.
**The fix:** Always run Coach's review after analysis. It takes 10-15 minutes and dramatically improves model accuracy for future runs. Your amendments persist — this is an investment, not a chore.
### 2. "Treating Severity as Absolute"
**The mistake:** Treating every "blocker" as a launch-blocking issue and every "nice-to-have" as ignorable.
**Why it fails:** Severity is contextual. A "blocker" finding about missing Kubernetes liveness probes is irrelevant if you're deploying to Lambda. A "nice-to-have" finding about structured logging becomes critical if you're under a compliance audit.
**The fix:** Use Coach's review to adjust severity based on your context. Lens assigns severity based on general best practices; you assign severity based on your reality.
### 3. "Ignoring Compound Findings"
**The mistake:** Reading individual findings one by one and missing the compound patterns.
**Why it fails:** Individual findings tell you what's absent. Compound findings tell you what's *dangerous* — they surface risk amplification that single-domain analysis can't see. "No health checks" is a problem. "No health checks AND no rollback" is a crisis.
**The fix:** Read compound findings first. They're the highest-signal items in the report because they represent the intersection of two weaknesses.
### 4. "Deleting `.gyre/` to Start Over"
**The mistake:** Deleting the `.gyre/` directory every time you want a fresh analysis.
**Why it fails:** `.gyre/` contains your amendments (GC4). Deleting it throws away every keep/remove/edit decision you made during Coach review. Gyre has to start from zero.
**The fix:** If you want a fresh model, use Atlas's Regeneration mode. It rebuilds the model while respecting your amendments. If you truly need to start from scratch (e.g., wrong project), then deletion is appropriate — but know what you're losing.
### 5. "Using Gyre as a Linter"
**The mistake:** Expecting Gyre to find code quality issues, bugs, or style violations.
**Why it fails:** Gyre does *absence detection*, not code analysis. It finds what's missing (no health check endpoint), not what's wrong (health check endpoint returns 200 when database is down). These are complementary but different tools.
**The fix:** Use Gyre for production readiness gaps. Use linters, SAST tools, and code review for code quality. Gyre answers "are we ready to ship?" not "is our code good?"
### 6. "Not Committing `.gyre/` Artifacts"
**The mistake:** Adding `.gyre/` to `.gitignore` or never committing the artifacts.
**Why it fails:** Gyre artifacts are designed to be shared. When one team member reviews and customizes the model (amendments + feedback), those improvements benefit everyone. Without committing, each team member starts from scratch.
**The fix:** Commit `.gyre/` to your repo. The privacy boundary ensures no sensitive data is included. Model amendments and feedback become shared team knowledge.
### 7. "Skipping Guard Questions"
**The mistake:** Rushing through Scout's guard questions with default answers.
**Why it fails:** Guard answers flow downstream to Atlas and Lens. If you tell Scout your deployment model is "container-based" when it's actually serverless, Atlas generates irrelevant capabilities (Kubernetes probes for a Lambda function) and Lens searches for things that don't apply.
**The fix:** Answer guard questions accurately. They're asked because Scout genuinely couldn't determine the answer from the filesystem. Your 30 seconds of accuracy save 30 minutes of irrelevant findings.
---
## Gyre + Vortex: Inter-Module Routing
Gyre findings can feed into Vortex product discovery when readiness gaps have strategic implications:
| If Gyre Finds... | Consider in Vortex... | Agent | Why |
|---|---|---|---|
| Critical readiness gaps blocking launch | Product Vision or Contextualize Scope | Emma 🎯 | Readiness gaps may redefine product scope |
| Findings that challenge assumptions | Hypothesis Engineering | Liam 💡 | Readiness findings are testable hypotheses |
| Feedback suggesting missing capabilities | User Interview | Isla 🔍 | Validate missed gaps with real users |
This routing is advisory — you decide whether a Gyre finding warrants Vortex action.
---
## Quick Reference: Agent Cheat Sheet
| Agent | Stream | Core Question | Primary Workflows | Key Output |
|-------|--------|--------------|-------------------|------------|
| Scout 🔎 | Detect | What's this project built with? | Stack Detection, Full Analysis | GC1 Stack Profile |
| Atlas 📐 | Model | What should a project like this have? | Model Generation, Accuracy Validation | GC2 Capabilities Manifest |
| Lens 🔬 | Analyze | What's missing? | Gap Analysis, Delta Report | GC3 Findings Report |
| Coach 🏋️ | Review | Is the model right for *our* stack? | Model Review (findings + capabilities) | GC4 Amendments + Feedback |
## Three Modes of Operation
| Mode | Trigger | What Runs | When to Use |
|------|---------|-----------|-------------|
| **Crisis** | No `.gyre/` directory | Full pipeline from scratch | First run on a project |
| **Anticipation** | `.gyre/` exists | Gap analysis + review only (model cached) | Routine check-ins |
| **Regeneration** | Explicit user request | Fresh model generation + full pipeline | After stack changes or when model feels stale |
---
## Further Reading
- **Per-agent guides:** See `SCOUT-USER-GUIDE.md` through `COACH-USER-GUIDE.md` in this directory for invocation details, menu options, and troubleshooting
- **Compass Routing Reference:** `_bmad/bme/_gyre/compass-routing-reference.md` — the authoritative routing table
- **Handoff Contract Schemas:** `_bmad/bme/_gyre/contracts/` — the exact artifact formats
- **`.gyre/` directory:** Your project's artifacts — safe to commit, designed to be shared
---
*Gyre doesn't tell you what's wrong with your code. It tells you what's missing from your operations — and gives you the model to track it.*
# The Vortex Deep Dive — Product Discovery from Context to Decision
**Team:** Vortex Pattern (7 Agents, 22 Workflows, 10 Handoff Contracts)
**Module:** Convoke (bme)
**Version:** 3.0.4
**Last Updated:** 2026-04-05
---
## What This Guide Covers
The per-agent user guides tell you *how to use each agent*. This guide tells you *how the team works together* — the pipeline logic, what flows between agents, what good artifacts look like, where teams get stuck, and how to navigate the system like someone who built it.
If you've read the individual user guides for Emma, Isla, Mila, Liam, Wade, Noah, and Max, you're ready for this.
---
## The Vortex at a Glance
The Vortex is a 7-stream product discovery framework based on the [Innovation Vortex](https://unfix.com/innovation-vortex) from the unFIX model. Each stream has a dedicated agent. Artifacts flow forward through handoff contracts, while feedback loops route backward when evidence demands it.
```
Emma 🎯 Isla 🔍 Mila 🔬 Liam 💡 Wade 🧪 Noah 📡 Max 🧭
Contextualize → Empathize → Synthesize → Hypothesize → Externalize → Sensitize → Systematize
(strategy) (research) (convergence) (hypotheses) (experiments) (production) (decisions)
```
**The big idea:** You don't build features. You discover whether a problem is worth solving, for whom, and how — then you test that bet as cheaply as possible before committing resources. The Vortex gives that process structure.
---
## How to Read This Guide
This guide is organized around five layers:
1. **The Pipeline** — How artifacts flow from one agent to the next
2. **Walkthrough: A Complete Vortex Cycle** — Step-by-step through a realistic scenario
3. **Artifact Examples** — What good output looks like at each stage
4. **Scenarios & Entry Points** — Where to start depending on your situation
5. **Anti-Patterns** — Where teams go wrong and how to avoid it
---
## Part 1: The Pipeline
### Forward Flow (HC1-HC5)
The core pipeline moves artifacts forward through five handoff contracts:
| Contract | From | To | What Flows | In Plain English |
|----------|------|-----|-----------|------------------|
| **HC1** | Isla 🔍 | Mila 🔬 | Empathy artifacts (maps, interviews, observations) with synthesized insights | "Here's everything we learned from talking to users" |
| **HC2** | Mila 🔬 | Liam 💡 | Converged problem definition (JTBD + Pains & Gains + evidence) | "Here's the single problem worth solving, and why we believe it" |
| **HC3** | Liam 💡 | Wade 🧪 | 1-3 hypothesis contracts with risk maps and testing order | "Here are our bets, ranked by what could kill us fastest" |
| **HC4** | Wade 🧪 | Noah 📡 | Experiment context (results, baselines, success criteria) | "Here's what we tested and what happened — watch these signals in production" |
| **HC5** | Noah 📡 | Max 🧭 | Signal report (production intelligence + experiment lineage) | "Here's what production is telling us, mapped back to our original hypotheses" |
### Feedback Loops (HC6-HC10)
When evidence says "go back," these contracts route work backward:
| Contract | From | To | Trigger | In Plain English |
|----------|------|-----|---------|------------------|
| **HC6** | Max 🧭 | Mila 🔬 | Pivot decision — problem correct, solution wrong | "Our problem is real but our approach failed. Re-synthesize." |
| **HC7** | Max 🧭 | Isla 🔍 | Critical evidence gap identified | "We're missing something fundamental. Go find out." |
| **HC8** | Max 🧭 | Emma 🎯 | Problem space itself is wrong | "We've been solving the wrong problem entirely." |
| **HC9** | Liam 💡 | Isla 🔍 | Unvalidated lethal assumption detected mid-hypothesis | "This assumption could kill us and we have zero evidence. Validate before proceeding." |
| **HC10** | Noah 📡 | Isla 🔍 | Anomalous user behavior in production | "Users are doing something we never predicted. Investigate." |
### The Pipeline Diagram
```
┌─────────────────────────────────────────────┐
│ VORTEX PATTERN │
│ 7 Streams · 7 Agents │
└─────────────────────────────────────────────┘
┌──────────┐ HC1 ┌──────────┐ HC2 ┌──────────┐ HC3 ┌──────────┐
│ Isla 🔍 │─────────▶│ Mila 🔬 │─────────▶│ Liam 💡 │─────────▶│ Wade 🧪 │
│ Empathize │ artifact │Synthesize│ artifact │Hypothesiz│ artifact │Externaliz│
└──────────┘ └──────────┘ └──────────┘ └──────────┘
▲ ▲ │ │
│ │ HC9│flag HC4│artifact
│ HC6│routing │ │
│ │ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Emma 🎯 │◀── HC8 ──│ Max 🧭 │◀── HC5 ──│ Noah 📡 │◀─────────┘
│Contextual│ routing │Systematiz│ artifact │ Sensitize│
└──────────┘ └──────────┘ └──────────┘
│ │ │
│ HC7│routing HC10│flag
│ │ │
└──────────────────────┴────────────────────┘
▼ to Isla 🔍
```
**Isla is the gravity well.** She has three formal inbound feedback contracts (HC7, HC9, HC10) plus organic routing from multiple workflows. When in doubt, the Vortex sends you back to the user research.
---
## Part 2: Walkthrough — A Complete Vortex Cycle
Let's walk through a realistic scenario end-to-end. You're a product team at a B2B SaaS company exploring whether to build an onboarding assistant for new customers.
### Stage 1: Emma — Contextualize the Problem Space
**What you do:** Invoke Emma and run **[LP] Create Lean Persona** and **[PV] Define Product Vision**.
**Step-by-step through Lean Persona:**
| Step | What Emma Asks | What a Good Answer Looks Like |
|------|---------------|-------------------------------|
| 1. Define Job-to-be-Done | "What job is the user trying to accomplish?" | "New B2B customers need to configure their first integration within 48 hours of purchase so they see value before buyer's remorse sets in." (Specific job, not vague "onboard customers") |
| 2. Current Solution Analysis | "How do they solve this today? What hurts?" | "They read a 40-page setup guide, attend a 1-hour onboarding call, and still email support 2-3 times. Average time-to-first-value: 12 days." |
| 3. Problem Contexts | "When and where does this occur?" | "Immediately post-purchase. The champion who bought is under pressure to show ROI to their manager within 30 days." |
| 4. Forces & Anxieties | "What pushes/holds them?" | "Push: boss expects demo in 2 weeks. Hold: fear of breaking their existing integrations. Anxiety: 'What if I configured it wrong and corrupt our data?'" |
| 5. Success Criteria | "What does success look like?" | "First integration live within 48 hours. Zero support tickets during setup. Champion feels confident enough to onboard their own team." |
| 6. Synthesize | "What are your 3 riskiest assumptions?" | See artifact example below |
**What you get:** A lean persona artifact file like `lean-persona-setup-champion-2026-04-05.md` — a hypothesis about your user, their job, their pains, and what you need to validate.
**The Compass suggests:** Head to Isla (user-interview) to validate persona assumptions, or to Wade (lean-experiment) if you want to test the riskiest one immediately.
### Stage 2: Isla — Empathize Through Discovery
**What you do:** Invoke Isla and run **[UI] User Interview** to validate Emma's lean persona with real users.
Isla guides you through structuring interview questions around the JTBD and pains you identified. She helps you avoid leading questions and focus on behavior observation rather than opinion collection.
**What flows to Mila (HC1):** Empathy artifacts containing interview findings, key themes, pain patterns, and desired gains — all grounded in evidence from real conversations.
### Stage 3: Mila — Synthesize Into a Problem Definition
**What you do:** Invoke Mila and run **[RC] Research Convergence**.
Mila loads Isla's HC1 artifacts and converges them into a single, actionable problem definition. She uses JTBD framing and Pains & Gains analysis to distill multiple research inputs into one coherent picture.
**What flows to Liam (HC2):** A problem definition artifact with:
- A converged problem statement with confidence level
- Primary JTBD ("When [situation], I want to [motivation], so I can [expected outcome]")
- Prioritized pains with evidence sources
- Prioritized gains with expected impact
- An evidence summary showing traceability
- Embedded assumptions with validation status
### Stage 4: Liam — Engineer Testable Hypotheses
**What you do:** Invoke Liam and run **[HE] Hypothesis Engineering**.
Liam loads Mila's HC2 problem definition and turns it into 1-3 rigorous, falsifiable hypotheses using structured brainwriting and the 4-field contract format.
**Step-by-step:**
| Step | What Liam Does | What Matters |
|------|---------------|-------------|
| 1. Setup | Validates HC2 input — is the problem definition strong enough? | If the problem definition is weak, Liam sends you back to Mila |
| 2. Problem Context | Unpacks JTBD, pains, gains, and maps the hypothesis landscape | This is where Liam identifies which pains are most "hypothesis-worthy" |
| 3. Brainwriting | Engineers 1-3 hypotheses using the 4-field format | Each hypothesis has: Expected Outcome, Target Behavior Change, Rationale, Riskiest Assumption |
| 4. Assumption Mapping | Maps every assumption by lethality x uncertainty | The kill chart: what could destroy your idea, and how sure are you about it? |
| 5. Synthesize | Produces HC3 artifact with testing order | The riskiest assumption becomes the first experiment target |
**The HC9 flag:** If Liam encounters an assumption so dangerous and unvalidated that testing it without prior research is reckless, he flags it via HC9 and routes you back to Isla for targeted discovery. This is a safety valve — it prevents you from running experiments on foundations of sand.
**What flows to Wade (HC3):** 1-3 hypothesis contracts, each with the 4-field format, plus a risk map and recommended testing order.
### Stage 5: Wade — Run Lean Experiments
**What you do:** Invoke Wade and run **[LE] Lean Experiment**.
Wade loads Liam's HC3 artifact and designs the cheapest, fastest experiment to test the riskiest assumption first. He follows Build-Measure-Learn.
**Step-by-step through Lean Experiment:**
| Step | What Wade Does | What Matters |
|------|---------------|-------------|
| 1. Hypothesis | Loads hypothesis from HC3, confirms it's testable | The hypothesis statement becomes the experiment brief |
| 2. Design | Designs the experiment (type, audience, timeline) | Wade pushes for the cheapest valid test — a landing page over a prototype, a prototype over an MVP |
| 3. Metrics | Defines success criteria and measurement plan | "If we see X, the hypothesis is supported. If we see Y, it's invalidated." |
| 4. Run | Guides you through execution | The actual experiment happens outside the tool — Wade tracks it |
| 5. Analyze | Analyzes results against success criteria | Raw data becomes insight |
| 6. Decide | Experiment conclusion and next-step recommendation | Validated? Continue forward. Invalidated? Route to Max for a strategic decision |
**What flows to Noah (HC4):** Experiment context including results, baselines, success criteria, and which hypotheses were confirmed or rejected.
### Stage 6: Noah — Read Production Signals
**What you do:** Invoke Noah and run **[SI] Signal Interpretation**.
Noah loads Wade's HC4 experiment context and interprets production signals through the lens of your original hypotheses. He doesn't just report metrics — he maps them back to the bets you made.
**The HC10 flag:** If Noah detects user behavior that doesn't match any hypothesis — something completely unexpected — he flags it via HC10 and routes you back to Isla to investigate. Anomalies are discovery opportunities.
**What flows to Max (HC5):** A signal report containing signal descriptions, experiment lineage, and trend analysis. Intelligence only — no strategic recommendations (that's Max's job).
### Stage 7: Max — Make the Strategic Decision
**What you do:** Invoke Max and run **[PP] Pivot / Patch / Persevere**.
Max loads Noah's HC5 signal report (and any accumulated learning cards) and guides you through a rigorous strategic decision.
**Step-by-step:**
| Step | What Max Does | What Matters |
|------|---------------|-------------|
| 1. Evidence Review | Gathers all learning cards, experiment results, signals | Everything on the table — no cherry-picking |
| 2. Hypothesis Assessment | Scores each original hypothesis: confirmed, partial, invalidated | Honest assessment against evidence |
| 3. Option Analysis | Analyzes Pivot, Patch, and Persevere with pros/cons/risks | Forces you to consider all three options seriously |
| 4. Stakeholder Input | Captures team perspectives and concerns | Prevents unilateral decisions |
| 5. Decision | Documents the decision with rationale | The record says WHY, not just WHAT |
| 6. Action Plan | Creates concrete next steps for the chosen direction | A decision without an action plan is just an opinion |
**The routing from Max:**
- **Pivot** → HC6 to Mila (re-synthesize problem with new evidence)
- **Patch** → Back to Wade (adjust experiment and re-test)
- **Persevere** → HC7 to Isla (if deeper insight needed) or continue to next development phase
- **Wrong problem entirely** → HC8 to Emma (recontextualize)
---
## Part 3: Artifact Examples
### Lean Persona (Emma's Output)
```markdown
# Lean Persona: Setup Champion
## Job-to-be-Done
**When** a B2B customer purchases our platform,
**I want to** configure my first integration within 48 hours,
**so I can** demonstrate value to my manager before buyer's remorse sets in.
**Frequency:** Once per customer lifecycle (critical window)
**Importance:** Mission-critical — failure here drives churn
## Current Solution
- 40-page setup guide (avg. read time: never)
- 1-hour onboarding call (booked 5-7 days post-purchase)
- 2-3 support emails during setup
- Average time-to-first-value: 12 days
## Pain Points
1. **Gap between purchase excitement and first value** — 12-day gap kills momentum
2. **Fear of misconfiguration** — "What if I break our existing integrations?"
3. **Setup guide assumes expert knowledge** — new users don't know the terminology
## Riskiest Assumptions
1. ASSUMPTION: Customers would self-serve if the guide were interactive (not PDF)
2. ASSUMPTION: The 48-hour window is the real deadline (not 30 days)
3. ASSUMPTION: Fear of misconfiguration is the primary blocker (not complexity)
```
### HC2 Problem Definition (Mila's Output)
```markdown
## Converged Problem Statement
New B2B customers experience a critical 12-day gap between purchase and first
value realization, driven by configuration complexity and fear of breaking
existing integrations. This gap directly correlates with 60-day churn rate.
**Confidence:** Medium (5 interviews + support ticket analysis; no quantitative validation yet)
## Primary JTBD
When I purchase a new B2B platform,
I want to see it working with my data within 48 hours,
so I can justify the purchase to my manager and feel confident in my decision.
## Pains (Prioritized)
| Pain | Priority | Evidence |
|------|----------|----------|
| 12-day time-to-first-value | High | 5/5 interviewees cited frustration |
| Fear of misconfiguration | High | 4/5 interviewees delayed setup due to fear |
| Setup guide assumes expertise | Medium | 3/5 interviewees couldn't parse terminology |
## Assumptions
| Assumption | Risk if Wrong | Status |
|-----------|--------------|--------|
| 48-hour window is the real deadline | Solving for wrong urgency | Assumed |
| Fear > complexity as primary blocker | Wrong intervention design | Partially Validated |
```
### HC3 Hypothesis Contract (Liam's Output)
```markdown
## Hypothesis 1: Guided Setup Reduces Time-to-Value
**Expected Outcome:** Interactive setup wizard reduces time-to-first-value
from 12 days to under 48 hours for 70% of new customers.
**Target Behavior Change:** Customers complete first integration without
contacting support (currently 0% self-serve; target: 60%).
**Rationale:** Interview evidence shows customers abandon the PDF guide
within 5 minutes. Interactive guidance addresses both complexity and
fear-of-misconfiguration by providing guardrails.
**Riskiest Assumption:** Customers would actually use a self-serve wizard
instead of waiting for the onboarding call. (If they prefer human
guidance regardless, the wizard investment is wasted.)
## Assumption Risk Map
| Assumption | Lethality | Uncertainty | Priority |
|-----------|-----------|-------------|----------|
| Customers prefer self-serve over human onboarding | High | High | Test First |
| 48-hour window drives urgency | Medium | High | Test Soon |
| Fear of misconfiguration is solvable with UI guardrails | High | Medium | Test Soon |
```
---
## Part 4: Scenarios & Entry Points
Not every discovery starts at Stream 1. Here's where to enter the Vortex depending on your situation:
### "We have a new product idea but haven't talked to users yet"
**Start with:** Emma 🎯 → [LP] Lean Persona, then [PV] Product Vision
**Flow:** Emma → Isla (validate with users) → full forward pipeline
This is the classic greenfield path. Emma sets the strategic context, then Isla validates it with reality.
### "We have user research but it's scattered across docs and interviews"
**Start with:** Mila 🔬 → [RC] Research Convergence
**Flow:** Mila → Liam → Wade → Noah → Max
Skip Emma and Isla if you already have solid research. Mila will synthesize it into a problem definition. If Mila finds gaps in the research, she'll route you back to Isla.
### "We know the problem — we need hypotheses to test"
**Start with:** Liam 💡 → [HE] Hypothesis Engineering
**Flow:** Liam → Wade → Noah → Max
If you have a validated problem definition (or can write one that meets HC2 standards), jump straight to Liam.
### "We ran experiments but don't know what to do with the results"
**Start with:** Max 🧭 → [LC] Learning Card or [PP] Pivot / Patch / Persevere
**Flow:** Max decides the next direction
Max is the decision engine. Feed him evidence and he'll guide you through a structured decision.
### "We're in production and seeing weird signals"
**Start with:** Noah 📡 → [SI] Signal Interpretation
**Flow:** Noah → Max (for decision) or → Isla via HC10 (if anomalous)
Noah maps production behavior back to your original hypotheses. Unexpected behavior triggers deeper investigation.
### "We have everything — just need to navigate"
**Start with:** Max 🧭 → [VN] Vortex Navigation
**Flow:** Max runs a 7-stream gap analysis and tells you where you're weakest
Vortex Navigation is the GPS. It assesses your current state across all seven streams and recommends where to focus.
---
## Part 5: Anti-Patterns
### 1. "Demographic Persona Syndrome"
**The mistake:** Creating personas with names like "Sarah, 34, Marketing Manager, lives in Brooklyn, likes yoga."
**Why it fails:** Demographics don't predict behavior. A 34-year-old marketing manager and a 52-year-old engineering director can have the identical job-to-be-done. Emma will push back on this — lean personas focus on jobs, pains, and forces.
**The fix:** Start with the job. "When [situation], I want to [motivation], so I can [expected outcome]." Demographics are decoration; jobs are decision-drivers.
### 2. "Skipping Straight to Solutions"
**The mistake:** Going from Emma's lean persona directly to Wade's experiments without synthesizing the problem (Mila) or engineering hypotheses (Liam).
**Why it fails:** Without a converged problem definition, your experiments test vague ideas instead of specific hypotheses. Without explicit assumptions, you don't know what you're validating.
**The fix:** Respect the pipeline. Mila's convergence and Liam's hypothesis engineering are where vague intuitions become testable bets. You can move fast through them, but don't skip them.
### 3. "The Unfalsifiable Hypothesis"
**The mistake:** Writing hypotheses like "We believe users will like our product because it's better."
**Why it fails:** If you can't prove it wrong, it's not a hypothesis — it's a wish. Liam's 4-field format forces specificity: expected outcome (measurable), behavior change (observable), rationale (evidence-grounded), riskiest assumption (falsifiable).
**The fix:** Every hypothesis must have a riskiest assumption that, if wrong, kills the whole idea. If you can't name it, your hypothesis isn't specific enough.
### 4. "Sunk Cost Perseverance"
**The mistake:** Continuing to invest in a direction because you've already spent time/money on it, not because evidence supports it.
**Why it fails:** Evidence doesn't care about your budget. Max's Pivot / Patch / Persevere framework explicitly surfaces sunk-cost reasoning so you can recognize it.
**The fix:** Max requires you to analyze all three options (Pivot, Patch, Persevere) with evidence before deciding. "We've already invested X" is not evidence that the direction is correct — it's a cognitive bias.
### 5. "Ignoring the Feedback Loops"
**The mistake:** Treating the Vortex as a one-way conveyor belt: Emma → Isla → Mila → Liam → Wade → Noah → Max → done.
**Why it fails:** Discovery is iterative. Evidence will invalidate assumptions, reveal new problems, and surface surprises. The feedback loops (HC6-HC10) exist precisely because the forward path is rarely straight.
**The fix:** When Max says "pivot," trust the process and route back. When Liam flags an assumption (HC9), don't ignore it. When Noah sees an anomaly (HC10), investigate it. The loops are features, not failures.
### 6. "Testing Everything At Once"
**The mistake:** Running experiments on all three hypotheses simultaneously.
**Why it fails:** If multiple experiments run concurrently, you can't isolate which hypothesis is responsible for the signal. Liam's risk map exists to prioritize: test the riskiest assumption first, then the next.
**The fix:** Follow the recommended testing order from HC3. Test the assumption that could kill your idea first. If it survives, test the next one. Sequential testing is slower but produces clean evidence.
### 7. "Production Data Without Experiment Lineage"
**The mistake:** Treating production metrics as standalone signals without connecting them back to original hypotheses.
**Why it fails:** A metric going up means nothing without context. Noah's job is to interpret signals *through the lens of your hypotheses* — that's the experiment lineage. Without it, you're reacting to noise.
**The fix:** Always ensure Noah has Wade's HC4 experiment context before interpreting signals. The lineage is what turns data into intelligence.
---
## Quick Reference: Agent Cheat Sheet
| Agent | Stream | Core Question | Primary Workflows | Key Output |
|-------|--------|--------------|-------------------|------------|
| Emma 🎯 | Contextualize | Who are we building for and why? | Lean Persona, Product Vision, Contextualize Scope | Lean personas, product vision, problem scope |
| Isla 🔍 | Empathize | What do real users actually experience? | Empathy Map, User Interview, User Discovery | Empathy artifacts, interview findings, discovery research |
| Mila 🔬 | Synthesize | What's the single problem worth solving? | Research Convergence, Pivot Resynthesis, Pattern Mapping | HC2 problem definition (JTBD + Pains & Gains) |
| Liam 💡 | Hypothesize | What are our testable bets? | Hypothesis Engineering, Assumption Mapping, Experiment Design | HC3 hypothesis contracts with risk maps |
| Wade 🧪 | Externalize | What's the cheapest way to test this? | Lean Experiment, Proof of Concept, Proof of Value, MVP | Experiment results with baselines and success criteria |
| Noah 📡 | Sensitize | What is production telling us? | Signal Interpretation, Behavior Analysis, Production Monitoring | HC5 signal reports with experiment lineage |
| Max 🧭 | Systematize | What should we do with this evidence? | Learning Card, Pivot/Patch/Persevere, Vortex Navigation | Strategic decisions with rationale and action plans |
---
## Further Reading
- **Per-agent guides:** See `EMMA-USER-GUIDE.md` through `MAX-USER-GUIDE.md` in this directory for invocation details, menu options, and troubleshooting
- **Compass Routing Reference:** `_bmad/bme/_vortex/compass-routing-reference.md` — the authoritative routing table
- **Handoff Contract Schemas:** `_bmad/bme/_vortex/contracts/` — the exact artifact formats
- **Innovation Vortex (unFIX):** [unfix.com/innovation-vortex](https://unfix.com/innovation-vortex) — the original pattern this team implements
---
*The Vortex doesn't tell you what to build. It tells you what's worth building — and gives you the evidence to prove it.*
#!/usr/bin/env node
/**
* Convoke Check — Local CI mirror.
*
* Runs the same checks as .github/workflows/ci.yml so failures are caught
* before push. Intended for use in dev-story step 9 and manual pre-push.
*
* Steps (matching CI jobs):
* 1. Lint (npm run lint)
* 2. Unit tests (npm test)
* 3. Integration (npm run test:integration)
* 4. Jest lib (npx jest tests/lib/)
* 5. Coverage (npm run test:coverage) [--skip-coverage to omit]
*
* @module convoke-check
*/
const { execSync } = require('child_process');
const { findProjectRoot } = require('./update/lib/utils');
const STEPS = [
{ name: 'Lint', cmd: 'npm run lint' },
{ name: 'Unit tests', cmd: 'npm test' },
{ name: 'Integration tests', cmd: 'npm run test:integration' },
{ name: 'Jest lib tests', cmd: 'npx jest tests/lib/ --no-coverage' },
{ name: 'Coverage', cmd: 'npm run test:coverage', skippable: true }
];
function run() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Usage: convoke-check [options]
Runs the full CI-equivalent validation locally.
Options:
--skip-coverage Skip the coverage step (faster)
--help, -h Show this help
`);
return;
}
const projectRoot = findProjectRoot();
if (!projectRoot) {
console.error('Error: Not in a Convoke project. Could not find _bmad/ directory.');
process.exit(1);
}
const skipCoverage = args.includes('--skip-coverage');
const results = [];
let failed = false;
for (const step of STEPS) {
if (step.skippable && skipCoverage) {
results.push({ name: step.name, status: 'skipped' });
continue;
}
console.log(`\n--- ${step.name} ---`);
try {
execSync(step.cmd, { cwd: projectRoot, stdio: 'inherit' });
results.push({ name: step.name, status: 'pass' });
} catch {
results.push({ name: step.name, status: 'FAIL' });
failed = true;
// Continue to run remaining steps so all failures are visible
}
}
// Summary
console.log('\n=== Convoke Check Summary ===');
for (const r of results) {
const icon = r.status === 'pass' ? 'PASS' : r.status === 'skipped' ? 'SKIP' : 'FAIL';
console.log(` [${icon}] ${r.name}`);
}
if (failed) {
console.log('\nCI check FAILED. Fix the above issues before pushing.');
process.exit(1);
} else {
console.log('\nAll checks passed. Safe to push.');
}
}
run();
/**
* Shared artifact utilities for the Convoke governance system.
* Consumed by: migrate-artifacts.js, portfolio-engine.js, archive.js
*
* @module artifact-utils
* @see types.js for type definitions
*/
const fs = require('fs-extra');
const path = require('path');
const yaml = require('js-yaml');
const matter = require('gray-matter');
const { execFileSync } = require('child_process');
// --- Constants (extracted from archive.js) ---
/** Valid artifact category prefixes from the ADR naming convention */
const VALID_CATEGORIES = [
'prd', 'epic', 'arch', 'adr', 'brief', 'report', 'spec', 'vision',
'hc', 'persona', 'experiment', 'learning', 'sprint', 'decision',
'research'
];
/** Regex for valid lowercase kebab-case filenames */
const NAMING_PATTERN = /^[a-z][a-z0-9-]*\.(?:md|yaml)$/;
/** Regex to extract date suffix from filenames */
const DATED_PATTERN = /^(.+)-(\d{4}-\d{2}-\d{2})\.(md|yaml)$/;
/** Regex to extract category prefix from filenames */
const CATEGORIZED_PATTERN = /^([a-z]+\d*)-(.+)\.(md|yaml)$/;
// --- Filename Parsing ---
/**
* Check if a category string is in the valid categories list.
* Handles numeric suffixes (e.g., 'hc2' → check 'hc').
* @param {string} cat - Category to validate
* @returns {boolean}
*/
function isValidCategory(cat) {
const base = cat.replace(/\d+$/, '');
return VALID_CATEGORIES.includes(base) || VALID_CATEGORIES.includes(cat);
}
/**
* Parse a filename to extract naming convention components.
* Backward compatible — works with or without taxonomy parameter.
*
* @param {string} filename - The filename to parse (e.g., 'prd-gyre.md')
* @param {import('./types').TaxonomyConfig} [taxonomy] - Optional taxonomy for extended initiative inference
* @returns {import('./types').ParsedFilename}
*/
function parseFilename(filename, _taxonomy) {
const lower = filename.toLowerCase();
const dated = lower.match(DATED_PATTERN);
const categorized = lower.match(CATEGORIZED_PATTERN);
return {
filename,
isDated: !!dated,
date: dated ? dated[2] : null,
baseName: dated ? dated[1] : lower.replace(/\.(md|yaml)$/, ''),
category: categorized ? categorized[1] : null,
hasValidCategory: categorized ? isValidCategory(categorized[1]) : false,
isUppercase: filename !== lower,
matchesConvention: !!(NAMING_PATTERN.test(filename) && categorized && isValidCategory(categorized[1]))
};
}
/**
* Convert a filename to lowercase kebab-case.
* @param {string} filename
* @returns {string}
*/
function toLowerKebab(filename) {
return filename.toLowerCase();
}
// --- Directory Scanning ---
/**
* Scan artifact directories and return file inventory.
*
* @param {string} projectRoot - Absolute path to project root
* @param {string[]} includeDirs - Directory names to scan (relative to _bmad-output/)
* @param {string[]} [excludeDirs=['_archive']] - Directory names to exclude from results
* @returns {Promise<Array<{filename: string, dir: string, fullPath: string}>>}
*/
async function scanArtifactDirs(projectRoot, includeDirs, excludeDirs = ['_archive']) {
const outputDir = path.join(projectRoot, '_bmad-output');
const results = [];
for (const dir of includeDirs) {
if (excludeDirs.includes(dir)) continue;
const fullDir = path.join(outputDir, dir);
if (!await fs.pathExists(fullDir)) continue;
const files = (await fs.readdir(fullDir)).sort();
for (const filename of files) {
if (filename.startsWith('.')) continue;
const fullPath = path.join(fullDir, filename);
const stat = await fs.stat(fullPath);
if (!stat.isFile()) continue;
results.push({ filename, dir, fullPath });
}
}
return results;
}
// --- Taxonomy ---
/**
* Load and validate taxonomy configuration.
*
* @param {string} projectRoot - Absolute path to project root
* @returns {import('./types').TaxonomyConfig}
* @throws {Error} If file not found, malformed YAML, or invalid structure
*/
function readTaxonomy(projectRoot) {
const configPath = path.join(projectRoot, '_bmad', '_config', 'taxonomy.yaml');
if (!fs.existsSync(configPath)) {
throw new Error(
`Taxonomy config not found at ${configPath}. ` +
'Run convoke-migrate-artifacts or convoke-update to create it.'
);
}
let raw;
try {
raw = yaml.load(fs.readFileSync(configPath, 'utf8'));
} catch (err) {
throw new Error(
`Invalid YAML in taxonomy config: ${err.message}. File: ${configPath}`,
{ cause: err }
);
}
// Validate structure
if (!raw || typeof raw !== 'object') {
throw new Error(`Taxonomy config is empty or not an object. File: ${configPath}`);
}
if (!raw.initiatives || !Array.isArray(raw.initiatives.platform)) {
throw new Error(
'Taxonomy config missing required field: initiatives.platform (must be an array). ' +
`File: ${configPath}`
);
}
if (!Array.isArray(raw.artifact_types)) {
throw new Error(
'Taxonomy config missing required field: artifact_types (must be an array). ' +
`File: ${configPath}`
);
}
// Ensure optional fields have defaults
const config = {
initiatives: {
platform: raw.initiatives.platform || [],
user: raw.initiatives.user || []
},
artifact_types: raw.artifact_types || [],
aliases: raw.aliases || {}
};
// Validate entry format: lowercase alphanumeric with optional dashes
const idPattern = /^[a-z][a-z0-9-]*$/;
const allIds = [...config.initiatives.platform, ...config.initiatives.user];
for (const id of allIds) {
if (!idPattern.test(id)) {
throw new Error(
`Invalid initiative ID "${id}": must be lowercase alphanumeric with optional dashes. ` +
`File: ${configPath}`
);
}
}
for (const type of config.artifact_types) {
if (!idPattern.test(type)) {
throw new Error(
`Invalid artifact type "${type}": must be lowercase alphanumeric with optional dashes. ` +
`File: ${configPath}`
);
}
}
// Check for duplicates between platform and user
const platformSet = new Set(config.initiatives.platform);
for (const userId of config.initiatives.user) {
if (platformSet.has(userId)) {
throw new Error(
`Duplicate initiative ID "${userId}" found in both platform and user sections. ` +
`File: ${configPath}`
);
}
}
return config;
}
// --- Frontmatter ---
/**
* Parse frontmatter from file content.
*
* @param {string} fileContent - Raw file content string
* @returns {{data: Object, content: string}} Parsed frontmatter data and content below
*/
function parseFrontmatter(fileContent) {
if (typeof fileContent !== 'string') {
throw new Error('parseFrontmatter expects a string. Ensure files are read with utf8 encoding.');
}
try {
const parsed = matter(fileContent);
return { data: parsed.data, content: parsed.content };
} catch (err) {
throw new Error(`Failed to parse frontmatter: ${err.message}`, { cause: err });
}
}
/**
* Inject frontmatter fields into file content.
* Adds new fields, NEVER overwrites existing fields.
* Returns conflicts when existing field values differ from proposed values.
*
* @param {string} fileContent - Raw file content string
* @param {Object} newFields - Fields to inject (e.g., {initiative: 'helm', artifact_type: 'prd'})
* @returns {import('./types').InjectResult} Modified content + any detected conflicts
*/
function injectFrontmatter(fileContent, newFields) {
const parsed = matter(fileContent);
const conflicts = [];
// Detect conflicts: existing field has different value than proposed
for (const [key, value] of Object.entries(newFields)) {
if (parsed.data[key] !== undefined && parsed.data[key] !== value) {
conflicts.push({
field: key,
existingValue: parsed.data[key],
newValue: value
});
}
}
// Merge: new fields go first (for consistent ordering), existing fields override
// This means existing values are preserved — newFields only fill gaps
const merged = { ...newFields, ...parsed.data };
const content = matter.stringify(parsed.content, merged);
return { content, conflicts };
}
// --- Git Operations ---
/**
* Verify the working tree is clean within scope directories.
* Checks both tracked changes (staged + unstaged) and untracked files in scope.
*
* @param {string[]} scopeDirs - Directory names to check (relative to _bmad-output/)
* @param {string} projectRoot - Absolute path to project root
* @throws {Error} If working tree is dirty with details of dirty files
*/
function ensureCleanTree(scopeDirs, projectRoot) {
// Build scoped paths for git commands (forward slashes for git)
const scopePaths = scopeDirs.map(dir => `_bmad-output/${dir}`);
// Check tracked changes (staged and unstaged) — scoped to scopeDirs only
try {
execFileSync('git', ['diff', '--quiet', '--', ...scopePaths], { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' });
} catch {
let diff = '(unable to list files)';
try {
diff = execFileSync('git', ['diff', '--name-only', '--', ...scopePaths], { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }).trim();
} catch { /* best-effort */ }
throw new Error(
'Working tree has uncommitted changes in scope directories. Commit or stash before running migration.\n' +
`Dirty files:\n${diff}`
);
}
try {
execFileSync('git', ['diff', '--cached', '--quiet', '--', ...scopePaths], { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' });
} catch {
let staged = '(unable to list files)';
try {
staged = execFileSync('git', ['diff', '--cached', '--name-only', '--', ...scopePaths], { cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }).trim();
} catch { /* best-effort */ }
throw new Error(
'Working tree has staged changes in scope directories. Commit or stash before running migration.\n' +
`Staged files:\n${staged}`
);
}
// Check untracked files within scope directories
for (const scopePath of scopePaths) {
const untracked = execFileSync(
'git', ['ls-files', '--others', '--exclude-standard', scopePath],
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
).trim();
if (untracked) {
throw new Error(
`Untracked files found in ${scopePath}. Add or remove them before running migration.\n` +
`Untracked:\n${untracked}`
);
}
}
}
// --- Inference Engine ---
/** HC prefix pattern: matches hcN- at start of basename (e.g., hc2-, hc3-) */
const HC_PREFIX_PATTERN = /^hc\d+-/;
/**
* Maps long-form artifact type names found in existing filenames to canonical taxonomy types.
* Migration-specific — these are OLD naming patterns that don't match the taxonomy abbreviations.
*/
const ARTIFACT_TYPE_ALIASES = {
'problem-definition': 'problem-def',
'pre-registration': 'pre-reg',
'architecture': 'arch',
'hypothesis-contract': 'hypothesis'
};
/**
* Infer artifact type from a filename using greedy longest-prefix matching.
* Handles HC-prefixed files by stripping the HC prefix before matching.
*
* @param {string} filename - The filename to analyze (e.g., 'prd-gyre.md')
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy with artifact_types list
* @returns {{type: string|null, hcPrefix: string|null, remainder: string}} Inferred type, HC prefix if any, and remaining segments
*/
function inferArtifactType(filename, taxonomy) {
if (!filename || typeof filename !== 'string') {
return { type: null, hcPrefix: null, remainder: '', date: null, typeConfidence: 'low', typeSource: 'none' };
}
const lower = filename.toLowerCase();
// Strip extension
const withoutExt = lower.replace(/\.(md|yaml)$/, '');
// Strip date suffix if present
const dateMatch = withoutExt.match(/-(\d{4}-\d{2}-\d{2})$/);
const date = dateMatch ? dateMatch[1] : null;
const baseName = date ? withoutExt.slice(0, -(date.length + 1)) : withoutExt;
// Check for HC prefix (hc2-, hc3-, etc.)
let hcPrefix = null;
let nameToMatch = baseName;
const hcMatch = baseName.match(HC_PREFIX_PATTERN);
if (hcMatch) {
hcPrefix = hcMatch[0].slice(0, -1); // e.g., 'hc2' (without trailing dash)
nameToMatch = baseName.slice(hcMatch[0].length);
}
// Try artifact type aliases FIRST (longer, more specific — e.g., 'hypothesis-contract' before 'hypothesis')
const sortedAliasKeys = Object.keys(ARTIFACT_TYPE_ALIASES).sort((a, b) => b.length - a.length);
for (const aliasKey of sortedAliasKeys) {
if (nameToMatch.startsWith(aliasKey + '-') || nameToMatch === aliasKey) {
const canonicalType = ARTIFACT_TYPE_ALIASES[aliasKey];
const remainder = nameToMatch === aliasKey ? '' : nameToMatch.slice(aliasKey.length + 1);
return { type: canonicalType, hcPrefix, remainder, date, typeConfidence: 'high', typeSource: 'alias' };
}
}
// Then try direct match against taxonomy types (dash boundary, longest first)
const sortedTypes = [...taxonomy.artifact_types].sort((a, b) => b.length - a.length);
for (const type of sortedTypes) {
if (nameToMatch.startsWith(type + '-') || nameToMatch === type) {
const remainder = nameToMatch === type ? '' : nameToMatch.slice(type.length + 1);
return { type, hcPrefix, remainder, date, typeConfidence: 'high', typeSource: 'prefix' };
}
}
// No match
return { type: null, hcPrefix, remainder: nameToMatch, date, typeConfidence: 'low', typeSource: 'none' };
}
/**
* Infer which initiative owns an artifact based on the remaining filename segments.
* Five-step lookup: (1) exact match → (2) alias match → (3) progressive prefix → (4) progressive suffix → (5) first segment. Falls through to ambiguous if all steps fail.
*
* @param {string} remainder - Filename segments after type prefix and date are removed
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy with initiatives and aliases
* @returns {{initiative: string|null, confidence: 'high'|'low', source: string, candidates: string[]}}
*/
function inferInitiative(remainder, taxonomy) {
if (!remainder) {
return { initiative: null, confidence: 'low', source: 'empty', candidates: [] };
}
const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
const segments = remainder.split('-');
// Step 1: Try full remainder as exact initiative match
if (allInitiatives.includes(remainder)) {
return { initiative: remainder, confidence: 'high', source: 'exact', candidates: [] };
}
// Step 2: Try full remainder as alias match
if (taxonomy.aliases && taxonomy.aliases[remainder]) {
return { initiative: taxonomy.aliases[remainder], confidence: 'high', source: 'alias', candidates: [] };
}
// Step 3: Try progressive prefixes (longest first) against initiatives and aliases
// e.g., for 'strategy-perimeter-foo', try 'strategy-perimeter-foo', then 'strategy-perimeter', then 'strategy'
for (let i = segments.length - 1; i >= 1; i--) {
const prefix = segments.slice(0, i).join('-');
if (allInitiatives.includes(prefix)) {
return { initiative: prefix, confidence: 'high', source: 'exact', candidates: [] };
}
if (taxonomy.aliases && taxonomy.aliases[prefix]) {
return { initiative: taxonomy.aliases[prefix], confidence: 'high', source: 'alias', candidates: [] };
}
}
// Step 4: Try suffixes (last N segments) — catches 'prd-validation-gyre' → 'gyre'
for (let i = 1; i < segments.length; i++) {
const suffix = segments.slice(i).join('-');
if (allInitiatives.includes(suffix)) {
return { initiative: suffix, confidence: 'high', source: 'exact', candidates: [] };
}
if (taxonomy.aliases && taxonomy.aliases[suffix]) {
return { initiative: taxonomy.aliases[suffix], confidence: 'high', source: 'alias', candidates: [] };
}
}
// Step 5: Try first segment alone
const firstSegment = segments[0];
if (allInitiatives.includes(firstSegment)) {
return { initiative: firstSegment, confidence: 'high', source: 'exact', candidates: [] };
}
if (taxonomy.aliases && taxonomy.aliases[firstSegment]) {
return { initiative: taxonomy.aliases[firstSegment], confidence: 'high', source: 'alias', candidates: [] };
}
// Ambiguous — build candidate list from any partial matches
const candidates = allInitiatives.filter(id =>
segments.some(seg => seg === id || seg.startsWith(id) || id.startsWith(seg))
);
return { initiative: null, confidence: 'low', source: 'unresolved', candidates };
}
/**
* Determine the governance state of a file based on filename convention and frontmatter.
*
* @param {string} filename - The filename to check
* @param {string} fileContent - Raw file content (for frontmatter parsing)
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
* @returns {{state: 'fully-governed'|'half-governed'|'ungoverned'|'invalid-governed'|'ambiguous', fileInitiative: string|null, frontmatterInitiative: string|null, candidates: string[]}}
*/
function getGovernanceState(filename, fileContent, taxonomy) {
const typeResult = inferArtifactType(filename, taxonomy);
const initiativeResult = typeResult.type
? inferInitiative(typeResult.remainder, taxonomy)
: { initiative: null, confidence: 'low', source: 'no-type', candidates: [] };
const fileInitiative = initiativeResult.initiative;
// Check frontmatter
let frontmatterInitiative = null;
try {
const { data } = parseFrontmatter(fileContent);
frontmatterInitiative = data.initiative || null;
} catch {
// No valid frontmatter — treat as absent
}
// Determine state
if (typeResult.type === null) {
return { state: 'ungoverned', fileInitiative, frontmatterInitiative, candidates: [] };
}
// Type matched but initiative ambiguous — distinct from ungoverned
if (initiativeResult.confidence === 'low') {
return { state: 'ambiguous', fileInitiative, frontmatterInitiative, candidates: initiativeResult.candidates || [] };
}
if (!frontmatterInitiative) {
return { state: 'half-governed', fileInitiative, frontmatterInitiative, candidates: [] };
}
if (frontmatterInitiative !== fileInitiative) {
return { state: 'invalid-governed', fileInitiative, frontmatterInitiative, candidates: [] };
}
return { state: 'fully-governed', fileInitiative, frontmatterInitiative, candidates: [] };
}
/**
* Generate a new filename following the governance convention.
* Format: {initiative}-{artifactType}[-{qualifier}][-{date}].md
*
* @param {string} filename - Original filename
* @param {string} initiative - Resolved initiative ID
* @param {string} artifactType - Resolved artifact type
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
* @returns {string} New filename following convention
*/
function generateNewFilename(filename, initiative, artifactType, taxonomy) {
const typeResult = inferArtifactType(filename, taxonomy);
// Build qualifier from: HC prefix + remainder after initiative extraction
const parts = [];
// Add HC prefix as qualifier if present
if (typeResult.hcPrefix) {
parts.push(typeResult.hcPrefix);
}
// Extract qualifier: remainder minus the initiative segments
if (typeResult.remainder) {
const remainderSegments = typeResult.remainder.split('-');
const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
const aliasKeys = Object.keys(taxonomy.aliases || {});
// Try to find which segments the initiative match consumed
// Check prefixes (longest first)
let consumedStart = -1;
let consumedEnd = -1;
// Try prefix matches first (e.g., 'forge-phase-a' → 'forge' consumed at start)
for (let i = remainderSegments.length; i >= 1; i--) {
const prefix = remainderSegments.slice(0, i).join('-');
if (allInitiatives.includes(prefix) || aliasKeys.includes(prefix)) {
consumedStart = 0;
consumedEnd = i;
break;
}
}
// If no prefix match, try suffix matches (e.g., 'decision-strategy-perimeter' → 'strategy-perimeter' consumed at end)
if (consumedStart === -1) {
for (let i = 1; i < remainderSegments.length; i++) {
const suffix = remainderSegments.slice(i).join('-');
if (allInitiatives.includes(suffix) || aliasKeys.includes(suffix)) {
consumedStart = i;
consumedEnd = remainderSegments.length;
break;
}
}
}
// If still no match, try single first segment
if (consumedStart === -1) {
const first = remainderSegments[0];
if (allInitiatives.includes(first) || aliasKeys.includes(first)) {
consumedStart = 0;
consumedEnd = 1;
}
}
// Build qualifier from unconsumed segments
if (consumedStart >= 0) {
const before = remainderSegments.slice(0, consumedStart);
const after = remainderSegments.slice(consumedEnd);
const qualifierSegments = [...before, ...after];
if (qualifierSegments.length > 0) {
parts.push(qualifierSegments.join('-'));
}
} else {
// No initiative found — entire remainder is qualifier
parts.push(typeResult.remainder);
}
}
const qualifier = parts.length > 0 ? parts.join('-') : null;
// Build new filename
let newName = `${initiative}-${artifactType}`;
if (qualifier) {
newName += `-${qualifier}`;
}
if (typeResult.date) {
newName += `-${typeResult.date}`;
}
// Preserve original extension (.md or .yaml)
const extMatch = filename.match(/\.(md|yaml)$/i);
newName += extMatch ? `.${extMatch[1].toLowerCase()}` : '.md';
return newName;
}
// --- Schema Validation ---
/** Valid artifact-level status values (closed enum) */
const VALID_STATUSES = ['draft', 'validated', 'superseded', 'active'];
/** ISO 8601 date format: YYYY-MM-DD */
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
/**
* Validate frontmatter fields against the governance schema v1.
*
* @param {Object} fields - Frontmatter fields to validate
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config for initiative/type validation
* @returns {{valid: boolean, errors: string[]}} Validation result with error messages
*/
function validateFrontmatterSchema(fields, taxonomy) {
const errors = [];
// Required fields
if (!fields.initiative) {
errors.push('Missing required field: initiative');
}
if (!fields.artifact_type) {
errors.push('Missing required field: artifact_type');
}
if (!fields.created) {
errors.push('Missing required field: created');
}
if (fields.schema_version === undefined || fields.schema_version === null) {
errors.push('Missing required field: schema_version');
}
// schema_version must be integer >= 1
if (fields.schema_version !== undefined && fields.schema_version !== null) {
if (!Number.isInteger(fields.schema_version) || fields.schema_version < 1) {
errors.push(`Invalid schema_version "${fields.schema_version}": must be an integer >= 1`);
}
}
// created must be ISO 8601 date format
if (fields.created && !DATE_PATTERN.test(fields.created)) {
errors.push(`Invalid created date "${fields.created}": must be YYYY-MM-DD format`);
}
// status is optional but must be from closed enum if present
if (fields.status !== undefined && !VALID_STATUSES.includes(fields.status)) {
errors.push(`Invalid status "${fields.status}": must be one of ${VALID_STATUSES.join(', ')}`);
}
// initiative must exist in taxonomy
if (fields.initiative && taxonomy) {
const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
if (!allInitiatives.includes(fields.initiative)) {
errors.push(`Initiative "${fields.initiative}" not found in taxonomy (platform or user sections)`);
}
}
// artifact_type must exist in taxonomy
if (fields.artifact_type && taxonomy) {
if (!taxonomy.artifact_types.includes(fields.artifact_type)) {
errors.push(`Artifact type "${fields.artifact_type}" not found in taxonomy artifact_types list`);
}
}
return { valid: errors.length === 0, errors };
}
/**
* Build a complete frontmatter field set conforming to schema v1.
* Does NOT validate — use validateFrontmatterSchema() for that.
*
* @param {string} initiative - Initiative ID from taxonomy
* @param {string} artifactType - Artifact type from taxonomy
* @param {Object} [options={}] - Optional overrides (status, created)
* @param {string} [options.status] - Optional artifact status (draft/validated/superseded/active)
* @param {string} [options.created] - Optional created date (defaults to today YYYY-MM-DD)
* @returns {import('./types').FrontmatterSchema} Complete frontmatter fields
*/
function buildSchemaFields(initiative, artifactType, options = {}) {
const fields = {
initiative,
artifact_type: artifactType,
created: options.created || new Date().toISOString().split('T')[0],
schema_version: 1
};
if (options.status !== undefined) {
fields.status = options.status;
}
return fields;
}
// --- Manifest Generation ---
/**
* Get context clues for a file (first 3 lines + git author/date).
* Used in dry-run manifest for ambiguous/conflict files.
*
* @param {string} filePath - Absolute path to the file
* @param {string} projectRoot - Absolute path to project root
* @returns {Promise<{firstLines: string[], gitAuthor: string|null, gitDate: string|null}>}
*/
async function getContextClues(filePath, projectRoot) {
let firstLines = [];
try {
const content = await fs.readFile(filePath, 'utf8');
const lines = content.split('\n');
firstLines = lines.slice(0, 3).map(l => l.trimEnd());
} catch {
// File unreadable — return empty lines
}
let gitAuthor = null;
let gitDate = null;
try {
const raw = execFileSync(
'git', ['log', '-1', '--format=%an|%as', '--', path.relative(projectRoot, filePath)],
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
).trim();
if (raw) {
const parts = raw.split('|');
gitAuthor = parts[0] || null;
gitDate = parts[1] || null;
}
} catch {
// Not tracked in git or git unavailable
}
return { firstLines, gitAuthor, gitDate };
}
/**
* Find files that reference a target filename via markdown links or bare mentions.
* Only called when --verbose is set (reads every file in scope).
*
* @param {string} targetFilename - The filename to search for references to
* @param {Array<{filename: string, fullPath: string}>} scopeFiles - All files in scope
* @param {string} _projectRoot - Project root (unused, reserved for future)
* @returns {Promise<string[]>} List of filenames that reference the target
*/
async function getCrossReferences(targetFilename, scopeFiles, _projectRoot) {
const refs = [];
for (const file of scopeFiles) {
if (file.filename === targetFilename) continue;
if (!file.fullPath.endsWith('.md')) continue;
try {
const content = await fs.readFile(file.fullPath, 'utf8');
// Match: [text](targetFilename), [text](../dir/targetFilename), or bare targetFilename
if (content.includes(targetFilename)) {
refs.push(file.filename);
}
} catch {
// Skip unreadable files
}
}
return refs;
}
/**
* Build a single manifest entry for a file, classifying its action.
*
* @param {{filename: string, dir: string, fullPath: string}} fileInfo - File from scanArtifactDirs
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy config
* @param {string} _projectRoot - Project root (reserved)
* @returns {Promise<import('./types').ManifestEntry>}
*/
async function buildManifestEntry(fileInfo, taxonomy, _projectRoot) {
const { filename, dir, fullPath } = fileInfo;
const oldPath = `${dir}/${filename}`;
// Only process markdown files — YAML and other files are not migration targets
if (!filename.endsWith('.md')) {
return {
oldPath, newPath: null, initiative: null, artifactType: null,
confidence: 'low', source: 'non-markdown', action: 'SKIP',
dir, contextClues: null, crossReferences: null, candidates: [],
collisionWith: null, frontmatterInitiative: null, fileInitiative: null,
typeConfidence: 'low', typeSource: 'none'
};
}
let fileContent;
try {
fileContent = await fs.readFile(fullPath, 'utf8');
} catch {
return {
oldPath, newPath: null, initiative: null, artifactType: null,
confidence: 'low', source: 'unreadable', action: 'AMBIGUOUS',
dir, contextClues: null, crossReferences: null, candidates: [],
collisionWith: null, frontmatterInitiative: null, fileInitiative: null,
typeConfidence: 'low', typeSource: 'none'
};
}
// Single inference pass — getGovernanceState uses inferArtifactType + inferInitiative internally.
// We call inferArtifactType once here to get typeConfidence/typeSource for manifest display.
const typeResult = inferArtifactType(filename, taxonomy);
const govState = getGovernanceState(filename, fileContent, taxonomy);
const initConfidence = govState.state === 'ambiguous' || govState.state === 'ungoverned' ? 'low' : 'high';
const initSource = govState.state === 'ungoverned' ? 'no-type'
: govState.state === 'ambiguous' ? 'unresolved'
: govState.fileInitiative ? 'inferred' : 'none';
const base = {
oldPath, dir,
initiative: govState.fileInitiative,
artifactType: typeResult.type,
confidence: initConfidence,
source: initSource,
typeConfidence: typeResult.typeConfidence,
typeSource: typeResult.typeSource,
contextClues: null,
crossReferences: null,
candidates: govState.candidates || [],
collisionWith: null,
frontmatterInitiative: govState.frontmatterInitiative,
fileInitiative: govState.fileInitiative
};
if (govState.state === 'ungoverned') {
return { ...base, newPath: null, action: 'AMBIGUOUS' };
}
if (govState.state === 'ambiguous') {
return { ...base, newPath: null, action: 'AMBIGUOUS' };
}
if (govState.state === 'invalid-governed') {
return { ...base, newPath: null, action: 'CONFLICT' };
}
// Half-governed or fully-governed: type + initiative resolved
// Compare current filename with governance target to determine action
let newFilename;
try {
newFilename = generateNewFilename(filename, govState.fileInitiative, typeResult.type, taxonomy);
} catch {
// generateNewFilename failed — treat as ambiguous rather than aborting the entire manifest
return { ...base, newPath: null, action: 'AMBIGUOUS' };
}
const newPath = `${dir}/${newFilename}`;
if (govState.state === 'fully-governed') {
if (filename === newFilename) {
return { ...base, newPath: null, action: 'SKIP' };
}
return { ...base, newPath, action: 'RENAME' };
}
// half-governed
if (filename === newFilename) {
return { ...base, newPath: null, action: 'INJECT_ONLY' };
}
return { ...base, newPath, action: 'RENAME' };
}
/**
* Detect target filename collisions in manifest entries.
*
* @param {import('./types').ManifestEntry[]} entries - All manifest entries
* @returns {Map<string, string[]>} Map of colliding newPath -> list of oldPaths
*/
function detectCollisions(entries) {
const targetMap = new Map();
// Collect all target filenames (from RENAME entries)
for (const entry of entries) {
if (entry.action === 'RENAME' && entry.newPath) {
if (!targetMap.has(entry.newPath)) {
targetMap.set(entry.newPath, []);
}
targetMap.get(entry.newPath).push(entry.oldPath);
}
}
// Also check if any target matches an existing file (SKIP/INJECT entries)
const existingPaths = new Set(
entries.filter(e => e.action === 'SKIP' || e.action === 'INJECT_ONLY').map(e => e.oldPath)
);
for (const target of targetMap.keys()) {
if (existingPaths.has(target)) {
const sources = targetMap.get(target);
const sentinel = `(existing) ${target}`;
if (!sources.includes(sentinel)) {
sources.push(sentinel);
}
}
}
// Filter to only actual collisions (more than 1 source)
const collisions = new Map();
for (const [target, sources] of targetMap) {
if (sources.length > 1) {
collisions.set(target, sources);
}
}
return collisions;
}
/**
* Generate the full dry-run manifest for all in-scope artifact directories.
*
* @param {string} projectRoot - Absolute path to project root
* @param {Object} [options={}]
* @param {string[]} [options.includeDirs=['planning-artifacts','vortex-artifacts','gyre-artifacts']]
* @param {string[]} [options.excludeDirs=['_archive']]
* @param {boolean} [options.verbose=false]
* @returns {Promise<import('./types').ManifestResult>}
*/
async function generateManifest(projectRoot, options = {}) {
const {
includeDirs = ['planning-artifacts', 'vortex-artifacts', 'gyre-artifacts'],
excludeDirs = ['_archive'],
verbose = false
} = options;
const taxonomy = readTaxonomy(projectRoot);
const scopeFiles = await scanArtifactDirs(projectRoot, includeDirs, excludeDirs);
const entries = [];
for (const fileInfo of scopeFiles) {
const entry = await buildManifestEntry(fileInfo, taxonomy, projectRoot);
entries.push(entry);
}
// Detect collisions and annotate entries
const collisions = detectCollisions(entries);
for (const [target, sources] of collisions) {
for (const entry of entries) {
if (entry.newPath === target && entry.action === 'RENAME') {
entry.collisionWith = sources.filter(s => s !== entry.oldPath);
}
}
}
// Gather context clues for AMBIGUOUS and CONFLICT entries
for (const entry of entries) {
if (entry.action === 'AMBIGUOUS' || entry.action === 'CONFLICT') {
const fullPath = path.join(projectRoot, '_bmad-output', entry.oldPath);
entry.contextClues = await getContextClues(fullPath, projectRoot);
if (verbose) {
entry.crossReferences = await getCrossReferences(
entry.oldPath.split('/').pop(),
scopeFiles,
projectRoot
);
}
}
}
// Build summary
const summary = { total: entries.length, skip: 0, rename: 0, inject: 0, conflict: 0, ambiguous: 0 };
for (const entry of entries) {
switch (entry.action) {
case 'SKIP': summary.skip++; break;
case 'RENAME': summary.rename++; break;
case 'INJECT_ONLY': summary.inject++; break;
case 'CONFLICT': summary.conflict++; break;
case 'AMBIGUOUS': summary.ambiguous++; break;
}
}
return { entries, collisions, summary };
}
/**
* Format the manifest as a human-readable text report.
*
* @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
* @param {Object} [options={}]
* @param {boolean} [options.verbose=false]
* @returns {string} Formatted manifest text
*/
function formatManifest(manifest, options = {}) {
const { verbose = false } = options;
const lines = [];
for (const entry of manifest.entries) {
switch (entry.action) {
case 'SKIP':
lines.push(`[SKIP] ${entry.oldPath} -- already governed`);
break;
case 'INJECT_ONLY':
lines.push(`[INJECT] ${entry.oldPath} -- frontmatter needed`);
break;
case 'RENAME':
lines.push(`${entry.oldPath} -> ${entry.newPath}`);
lines.push(` Initiative: ${entry.initiative} (confidence: ${entry.confidence}, source: ${entry.source})`);
lines.push(` Type: ${entry.artifactType} (confidence: ${entry.typeConfidence || 'high'}, source: ${entry.typeSource || 'prefix'})`);
if (entry.collisionWith && entry.collisionWith.length > 0) {
lines.push(` [!] COLLISION: same target as ${entry.collisionWith.join(', ')}`);
}
break;
case 'CONFLICT':
lines.push(`[!] ${entry.oldPath} -> CONFLICT (filename says ${entry.fileInitiative}, frontmatter says ${entry.frontmatterInitiative})`);
lines.push(' ACTION REQUIRED: Resolve initiative conflict before migration');
if (entry.contextClues) {
for (let i = 0; i < entry.contextClues.firstLines.length; i++) {
lines.push(` Line ${i + 1}: "${entry.contextClues.firstLines[i]}"`);
}
if (entry.contextClues.gitAuthor) {
lines.push(` Git author: ${entry.contextClues.gitAuthor} (${entry.contextClues.gitDate})`);
}
}
break;
case 'AMBIGUOUS': {
const typeLabel = entry.artifactType
? `type: ${entry.artifactType}, initiative unknown`
: 'cannot infer type or initiative';
lines.push(`[!] ${entry.oldPath} -> ??? (ambiguous -- ${typeLabel})`);
if (entry.contextClues) {
for (let i = 0; i < entry.contextClues.firstLines.length; i++) {
lines.push(` Line ${i + 1}: "${entry.contextClues.firstLines[i]}"`);
}
if (entry.contextClues.gitAuthor) {
lines.push(` Git author: ${entry.contextClues.gitAuthor} (${entry.contextClues.gitDate})`);
}
if (verbose && entry.crossReferences && entry.crossReferences.length > 0) {
lines.push(` Referenced by: ${entry.crossReferences.join(', ')}`);
}
}
if (entry.candidates.length > 0) {
lines.push(` Candidates: ${entry.candidates.join(', ')}`);
}
lines.push(' ACTION REQUIRED: Specify initiative for this file');
break;
}
}
}
// Summary footer
const s = manifest.summary;
lines.push('');
lines.push(`--- Manifest Summary ---`);
lines.push(`Total: ${s.total} | Rename: ${s.rename} | Skip: ${s.skip} | Inject: ${s.inject} | Conflict: ${s.conflict} | Ambiguous: ${s.ambiguous}`);
if (manifest.collisions.size > 0) {
lines.push(`[!] ${manifest.collisions.size} filename collision(s) detected -- resolve before executing`);
}
return lines.join('\n');
}
// --- Migration Execution ---
/**
* Structured error for migration failures. Named ArtifactMigrationError to avoid
* collision with MigrationError in scripts/update/lib/migration-runner.js.
*
* @property {string} file - Which file caused the error
* @property {'rename'|'inject'} phase - Drives programmatic rollback target
* @property {boolean} recoverable - Can re-run fix this?
*/
class ArtifactMigrationError extends Error {
constructor(message, { file = null, phase, recoverable = true } = {}) {
super(message);
this.name = 'ArtifactMigrationError';
this.file = file;
this.phase = phase;
this.recoverable = recoverable;
}
}
/**
* Execute all renames from a manifest as a single atomic git commit.
* If any git mv fails, rolls back ALL renames via git reset --hard HEAD.
*
* @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
* @param {string} projectRoot - Absolute path to project root
* @returns {{renamedCount: number, commitSha: string}} Result with count and commit SHA
* @throws {ArtifactMigrationError} On collision detection or git mv failure (after rollback)
*/
function executeRenames(manifest, projectRoot) {
const renameEntries = manifest.entries.filter(e => e.action === 'RENAME');
if (renameEntries.length === 0) {
return { renamedCount: 0, commitSha: null };
}
// Pre-flight: refuse to proceed if collisions exist
const colliding = renameEntries.filter(e => e.collisionWith && e.collisionWith.length > 0);
if (colliding.length > 0) {
const details = colliding.map(e => ` ${e.oldPath} -> ${e.newPath} (collides with ${e.collisionWith.join(', ')})`).join('\n');
throw new ArtifactMigrationError(
`Cannot execute renames: ${colliding.length} filename collision(s) detected.\n${details}`,
{ phase: 'rename', recoverable: false }
);
}
const outputDir = path.join(projectRoot, '_bmad-output');
// Execute all git mv operations
for (const entry of renameEntries) {
const oldFull = path.join(outputDir, entry.oldPath);
const newFull = path.join(outputDir, entry.newPath);
try {
execFileSync('git', ['mv', oldFull, newFull], { cwd: projectRoot, stdio: 'pipe' });
} catch (err) {
// Rollback ALL renames done so far
let rollbackOk = false;
try {
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
rollbackOk = true;
} catch { /* rollback failed — tree is dirty */ }
throw new ArtifactMigrationError(
`git mv failed for ${entry.oldPath} -> ${entry.newPath}: ${err.message}`,
{ file: entry.oldPath, phase: 'rename', recoverable: rollbackOk }
);
}
}
// Commit all renames as a single atomic commit (git mv already stages changes)
try {
execFileSync(
'git', ['commit', '-m', 'chore: rename artifacts to governance convention'],
{ cwd: projectRoot, stdio: 'pipe' }
);
} catch (err) {
// Commit failed after all git mv succeeded — rollback all renames
let rollbackOk = false;
try {
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
rollbackOk = true;
} catch { /* rollback failed */ }
throw new ArtifactMigrationError(
`git commit failed after renames: ${err.message}`,
{ phase: 'rename', recoverable: rollbackOk }
);
}
let commitSha = null;
try {
const shaOutput = execFileSync(
'git', ['rev-parse', 'HEAD'],
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
);
commitSha = (typeof shaOutput === 'string' ? shaOutput : shaOutput.toString('utf8')).trim();
} catch {
// Commit succeeded but SHA retrieval failed — non-fatal
}
return { renamedCount: renameEntries.length, commitSha };
}
/**
* Verify git history chain is preserved for a sample of renamed files.
* Informational only — does NOT rollback on failure.
*
* @param {import('./types').ManifestEntry[]} renamedEntries - Entries that were renamed
* @param {string} projectRoot - Absolute path to project root
* @returns {{verified: number, failed: string[]}} Verification result
*/
function verifyHistoryChain(renamedEntries, projectRoot) {
const sample = renamedEntries.slice(0, 5);
let verified = 0;
const failed = [];
for (const entry of sample) {
const fullPath = path.join(projectRoot, '_bmad-output', entry.newPath);
try {
const log = execFileSync(
'git', ['log', '--follow', '--oneline', '-3', '--', fullPath],
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
).trim();
const lines = log.split('\n').filter(Boolean);
if (lines.length >= 2) {
verified++;
} else {
failed.push(entry.newPath);
}
} catch {
failed.push(entry.newPath);
}
}
return { verified, failed };
}
/**
* Update internal markdown links in all .md files within scope after renames.
* Handles 4 patterns: [text](file.md), [text](./file.md), [text](../dir/file.md),
* and frontmatter inputDocuments arrays. Preserves anchor fragments.
*
* @param {Map<string, string>} oldToNewMap - Map of old basenames to new basenames
* @param {string[]} scopeDirs - Directory names to scan (relative to _bmad-output/)
* @param {string} projectRoot - Absolute path to project root
* @returns {Promise<{updatedFiles: number, updatedLinks: number}>}
*/
async function updateLinks(oldToNewMap, scopeDirs, projectRoot) {
const allFiles = await scanArtifactDirs(projectRoot, scopeDirs, ['_archive']);
let updatedFiles = 0;
let updatedLinks = 0;
for (const file of allFiles) {
if (!file.fullPath.endsWith('.md')) continue;
const original = fs.readFileSync(file.fullPath, 'utf8');
let content = original;
let fileLinks = 0;
// Parse frontmatter to handle inputDocuments arrays
const parsed = matter(content);
let fmChanged = false;
if (parsed.data && parsed.data.inputDocuments && Array.isArray(parsed.data.inputDocuments)) {
parsed.data.inputDocuments = parsed.data.inputDocuments.map(doc => {
if (typeof doc !== 'string') return doc;
for (const [oldName, newName] of oldToNewMap) {
// Exact match or path-suffix match (e.g., "dir/oldname.md") — prevents substring corruption
if (doc === oldName || doc.endsWith('/' + oldName)) {
fmChanged = true;
fileLinks++;
return doc === oldName ? newName : doc.slice(0, doc.length - oldName.length) + newName;
}
}
return doc;
});
}
// Reassemble content if frontmatter changed
if (fmChanged) {
content = matter.stringify(parsed.content, parsed.data);
}
// Update markdown link patterns in body content
for (const [oldName, newName] of oldToNewMap) {
// Escape dots for regex
const escaped = oldName.replace(/\./g, '\\.');
// Patterns 1+2: [text](oldname.md) or [text](./oldname.md) with optional anchor
const directPattern = new RegExp(
`(\\[[^\\]]*\\]\\()(\\.\\/)?${escaped}(#[^)]*)?\\)`,
'g'
);
// Pattern 3: [text](../dir/oldname.md) with optional anchor — replace only the filename
const parentDirPattern = new RegExp(
`(\\[[^\\]]*\\]\\([^)]*\\/)${escaped}(#[^)]*)?\\)`,
'g'
);
let bodyChanges = 0;
content = content.replace(directPattern, (_m, prefix, dotSlash, anchor) => {
bodyChanges++;
return `${prefix}${dotSlash || ''}${newName}${anchor || ''})`;
});
content = content.replace(parentDirPattern, (_m, prefix, anchor) => {
bodyChanges++;
return `${prefix}${newName}${anchor || ''})`;
});
fileLinks += bodyChanges;
}
if (content !== original) {
fs.writeFileSync(file.fullPath, content, 'utf8');
updatedFiles++;
updatedLinks += fileLinks;
}
}
return { updatedFiles, updatedLinks };
}
/**
* Execute commit 2: inject frontmatter into renamed files and update links.
* Runs AFTER executeRenames (commit 1) has completed.
*
* @param {import('./types').ManifestResult} manifest - Manifest from generateManifest()
* @param {string} projectRoot - Absolute path to project root
* @param {string[]} scopeDirs - Scope directories for link scanning
* @returns {Promise<{injectedCount: number, linkUpdates: {updatedFiles: number, updatedLinks: number}, conflictCount: number, commitSha: string|null}>}
* @throws {ArtifactMigrationError} On write failure (after rollback to commit 1)
*/
async function executeInjections(manifest, projectRoot, scopeDirs) {
const renameEntries = manifest.entries.filter(e => e.action === 'RENAME');
let injectedCount = 0;
let conflictCount = 0;
const outputDir = path.join(projectRoot, '_bmad-output');
// Build old->new basename map for link updating
const oldToNewMap = new Map();
for (const entry of renameEntries) {
const oldBasename = entry.oldPath.split('/').pop();
const newBasename = entry.newPath.split('/').pop();
if (oldBasename !== newBasename) {
oldToNewMap.set(oldBasename, newBasename);
}
}
// Inject frontmatter into each renamed file
for (const entry of renameEntries) {
const filePath = path.join(outputDir, entry.newPath);
try {
const content = fs.readFileSync(filePath, 'utf8');
const fields = buildSchemaFields(entry.initiative, entry.artifactType);
const result = injectFrontmatter(content, fields);
// Log conflicts
for (const c of result.conflicts) {
console.warn(` Warning: Skipping field "${c.field}" in ${entry.newPath}: existing value "${c.existingValue}" differs from proposed "${c.newValue}"`);
conflictCount++;
}
fs.writeFileSync(filePath, result.content, 'utf8');
injectedCount++;
} catch (err) {
// Write failure — rollback to commit 1
let rollbackOk = false;
try {
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
rollbackOk = true;
} catch { /* rollback failed */ }
throw new ArtifactMigrationError(
`Failed to inject frontmatter into ${entry.newPath}: ${err.message}`,
{ file: entry.newPath, phase: 'inject', recoverable: rollbackOk }
);
}
}
// Update internal links across all scoped .md files
const linkUpdates = await updateLinks(oldToNewMap, scopeDirs, projectRoot);
// Generate rename map (committed with injection phase)
const renameMapContent = generateRenameMap(renameEntries);
const renameMapPath = path.join(outputDir, 'planning-artifacts', 'artifact-rename-map.md');
fs.writeFileSync(renameMapPath, renameMapContent, 'utf8');
// Stage and commit (scoped to _bmad-output/)
try {
execFileSync('git', ['add', '_bmad-output/'], { cwd: projectRoot, stdio: 'pipe' });
execFileSync(
'git', ['commit', '-m', 'chore: inject frontmatter metadata and update links'],
{ cwd: projectRoot, stdio: 'pipe' }
);
} catch (err) {
let rollbackOk = false;
try {
execFileSync('git', ['reset', '--hard', 'HEAD'], { cwd: projectRoot, stdio: 'pipe' });
rollbackOk = true;
} catch { /* rollback failed */ }
throw new ArtifactMigrationError(
`git commit failed after injections: ${err.message}`,
{ phase: 'inject', recoverable: rollbackOk }
);
}
let commitSha = null;
try {
const shaOutput = execFileSync(
'git', ['rev-parse', 'HEAD'],
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
);
commitSha = (typeof shaOutput === 'string' ? shaOutput : shaOutput.toString('utf8')).trim();
} catch {
// Non-fatal — commit succeeded
}
return { injectedCount, linkUpdates, conflictCount, commitSha };
}
/**
* Prompt operator for initiative assignment on a single ambiguous file.
* Exported for mocking in tests — tests should NEVER interact with real readline.
*
* @param {string} filename - The ambiguous filename
* @param {string[]} candidates - Possible initiative matches
* @returns {Promise<string>} Selected initiative or 'skip'
*/
async function promptInitiative(filename, candidates) {
const readline = require('readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const options = [...candidates, 'skip'].join('/');
return new Promise(resolve => {
let resolved = false;
const done = (value) => { if (!resolved) { resolved = true; resolve(value); } };
rl.on('close', () => done('skip'));
rl.question(`Assign initiative for ${filename} [${options}]: `, answer => {
rl.close();
const trimmed = (answer || '').trim().toLowerCase();
if (trimmed === 'skip' || candidates.includes(trimmed)) {
done(trimmed);
} else {
done('skip');
}
});
});
}
/**
* Resolve ambiguous manifest entries interactively or auto-skip in force mode.
* Mutates manifest entries in-place.
*
* @param {import('./types').ManifestResult} manifest - Manifest to resolve
* @param {import('./types').TaxonomyConfig} taxonomy - Taxonomy for filename generation
* @param {string} _projectRoot - Project root (reserved)
* @param {Object} [options={}]
* @param {boolean} [options.force=false] - Auto-skip all ambiguous in force mode
* @returns {Promise<{resolved: number, skipped: number}>}
*/
async function resolveAmbiguous(manifest, taxonomy, _projectRoot, options = {}) {
const { force = false, promptFn = promptInitiative } = options;
let resolved = 0;
let skipped = 0;
for (const entry of manifest.entries) {
if (entry.action !== 'AMBIGUOUS') continue;
// Non-resolvable: no type or no candidates — auto-skip
if (!entry.artifactType || !entry.candidates || entry.candidates.length === 0) {
entry.action = 'SKIP';
skipped++;
continue;
}
// Force mode: auto-skip all ambiguous
if (force) {
entry.action = 'SKIP';
skipped++;
continue;
}
// Interactive prompt
const filename = entry.oldPath.split('/').pop();
const choice = await promptFn(filename, entry.candidates);
if (choice === 'skip') {
entry.action = 'SKIP';
skipped++;
} else {
entry.initiative = choice;
const newFilename = generateNewFilename(filename, choice, entry.artifactType, taxonomy);
entry.newPath = `${entry.dir}/${newFilename}`;
entry.action = 'RENAME';
entry.confidence = 'high';
entry.source = 'operator';
resolved++;
}
}
// Update summary counts
manifest.summary.rename = manifest.entries.filter(e => e.action === 'RENAME').length;
manifest.summary.skip = manifest.entries.filter(e => e.action === 'SKIP').length;
manifest.summary.ambiguous = manifest.entries.filter(e => e.action === 'AMBIGUOUS').length;
return { resolved, skipped };
}
/**
* Generate artifact-rename-map.md content as a markdown table.
*
* @param {import('./types').ManifestEntry[]} renamedEntries - Entries that were renamed
* @returns {string} Markdown content for the rename map file
*/
function generateRenameMap(renamedEntries) {
const date = new Date().toISOString().split('T')[0];
const lines = [
`# Artifact Rename Map`,
'',
`**Generated:** ${date}`,
`**Total renamed:** ${renamedEntries.length}`,
'',
'| Old Path | New Path |',
'|----------|----------|'
];
for (const entry of renamedEntries) {
lines.push(`| ${entry.oldPath} | ${entry.newPath} |`);
}
return lines.join('\n') + '\n';
}
/**
* Detect the current migration state for idempotent recovery.
* Uses commit message as primary signal (inference engine can't recognize
* initiative-first filenames after rename — see ag-3-3 Dev Notes).
*
* @param {string} projectRoot - Absolute path to project root
* @returns {'complete'|'renames-done'|'fresh'} Current migration state
*/
/**
* Generate the content for the new governance convention ADR.
*
* @param {string} date - ISO date string (YYYY-MM-DD)
* @param {{renamedCount: number, injectedCount: number, linksUpdated: number, scopeDirs: string[]}} migrationStats
* @returns {string} Markdown content for the ADR file
*/
function generateGovernanceADR(date, migrationStats = {}) {
const { renamedCount = 0, injectedCount = 0, linksUpdated = 0, scopeDirs = [] } = migrationStats;
return `# Architecture Decision Record: Artifact Governance Convention
**Status:** ACCEPTED
**Date:** ${date}
**Decision Makers:** Convoke migration tool
**Supersedes:** adr-repo-organization-conventions-2026-03-22.md
---
## Context
The project accumulated artifacts across multiple initiatives (Vortex, Gyre, Forge, Helm, Enhance, Loom, Convoke) using inconsistent naming conventions. Files like \`prd-gyre.md\`, \`architecture-gyre.md\`, and \`hc2-problem-definition-gyre-2026-03-21.md\` followed different patterns, making it difficult to identify which initiative owned each artifact and to build automated tooling on top of the artifact structure.
## Decision
All artifacts within \`_bmad-output/\` follow the governance naming convention:
\`\`\`
{initiative}-{artifact_type}[-{qualifier}][-{date}].md
\`\`\`
**Examples:**
- \`gyre-prd.md\` (initiative: gyre, type: prd)
- \`helm-lean-persona-2026-04-04.md\` (initiative: helm, type: lean-persona, date)
- \`forge-problem-def-hc2-2026-03-21.md\` (initiative: forge, type: problem-def, qualifier: hc2, date)
## Taxonomy
**Platform initiatives (8):** vortex, gyre, bmm, forge, helm, enhance, loom, convoke
**Artifact types (21):** prd, epic, arch, adr, persona, lean-persona, empathy-map, problem-def, hypothesis, experiment, signal, decision, scope, pre-reg, sprint, brief, vision, report, research, story, spec
**Aliases (migration-specific):** Historical name variants mapped to canonical initiative IDs during migration (e.g., strategy-perimeter -> helm, team-factory -> loom).
## Frontmatter Schema v1
Every governed artifact includes YAML frontmatter with these required fields:
\`\`\`yaml
---
initiative: gyre # Required. From taxonomy.yaml
artifact_type: prd # Required. From taxonomy.yaml
created: 2026-04-06 # Required. ISO 8601 date
schema_version: 1 # Required. Integer >= 1
---
\`\`\`
Existing frontmatter fields are preserved — migration adds fields, never overwrites.
## Migration Scope
- **Directories:** ${scopeDirs.length > 0 ? scopeDirs.join(', ') : 'planning-artifacts, vortex-artifacts, gyre-artifacts'}
- **Files renamed:** ${renamedCount}
- **Frontmatter injected:** ${injectedCount}
- **Links updated:** ${linksUpdated}
- **Archive excluded:** \`_bmad-output/_archive/\` always excluded (FR50)
## Consequences
- All artifacts are discoverable by initiative and type via filename convention
- Automated portfolio tooling can infer initiative state from artifact metadata
- \`git log --follow\` preserves full history for renamed files
- The previous convention (type-first: \`prd-gyre.md\`) is superseded
`;
}
/**
* Update the previous ADR's status to SUPERSEDED and add a Superseded-by reference.
*
* @param {string} projectRoot - Absolute path to project root
* @param {string} newADRFilename - Filename of the new ADR (e.g., 'adr-artifact-governance-convention-2026-04-06.md')
* @returns {boolean} true if updated, false if old ADR not found
*/
function supersedePreviousADR(projectRoot, newADRFilename) {
const oldADRPath = path.join(projectRoot, '_bmad-output', 'planning-artifacts', 'adr-repo-organization-conventions-2026-03-22.md');
if (!fs.existsSync(oldADRPath)) {
console.warn('Warning: Previous ADR not found at expected path. Skipping supersession.');
return false;
}
let content = fs.readFileSync(oldADRPath, 'utf8');
// Replace status
content = content.replace('**Status:** ACCEPTED', '**Status:** SUPERSEDED');
// Insert Superseded-by line after the Supersedes line (guard against double-insertion on re-run)
const supersedesLine = '**Supersedes:** N/A (first formal repo organization standard)';
if (content.includes(supersedesLine) && !content.includes('**Superseded by:**')) {
content = content.replace(
supersedesLine,
`${supersedesLine}\n**Superseded by:** ${newADRFilename}`
);
}
fs.writeFileSync(oldADRPath, content, 'utf8');
return true;
}
function detectMigrationState(projectRoot) {
try {
// Check recent commits (not just last one) to handle intervening manual commits
const recentMsgs = execFileSync(
'git', ['log', '-5', '--format=%s'],
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
).trim().split('\n');
// Check in order: most recent first
for (const msg of recentMsgs) {
if (msg === 'chore: inject frontmatter metadata and update links' ||
msg === 'chore: generate governance convention ADR') {
return 'complete';
}
if (msg === 'chore: rename artifacts to governance convention') {
return 'renames-done';
}
}
} catch {
// Not a git repo or no commits — treat as fresh
}
return 'fresh';
}
// --- Exports ---
module.exports = {
// Constants
VALID_CATEGORIES,
NAMING_PATTERN,
DATED_PATTERN,
CATEGORIZED_PATTERN,
VALID_STATUSES,
// Filename parsing
isValidCategory,
parseFilename,
toLowerKebab,
// Directory scanning
scanArtifactDirs,
// Taxonomy
readTaxonomy,
// Frontmatter
parseFrontmatter,
injectFrontmatter,
// Schema
validateFrontmatterSchema,
buildSchemaFields,
// Inference
ARTIFACT_TYPE_ALIASES,
inferArtifactType,
inferInitiative,
getGovernanceState,
generateNewFilename,
// Git
ensureCleanTree,
// Manifest
getContextClues,
getCrossReferences,
buildManifestEntry,
detectCollisions,
generateManifest,
formatManifest,
// Execution
ArtifactMigrationError,
executeRenames,
verifyHistoryChain,
updateLinks,
executeInjections,
// Interactive & Recovery
promptInitiative,
resolveAmbiguous,
generateRenameMap,
detectMigrationState,
generateGovernanceADR,
supersedePreviousADR
};
/**
* Markdown formatter — standard markdown table output with confidence markers.
*
* @module markdown-formatter
*/
/**
* Format InitiativeState array as markdown table.
*
* @param {import('../../types').InitiativeState[]} initiatives
* @returns {string}
*/
function formatMarkdown(initiatives) {
if (initiatives.length === 0) {
return 'No initiatives found.\n';
}
const lines = [];
lines.push('| Initiative | Phase | Status | Next Action / Context |');
lines.push('|------------|-------|--------|----------------------|');
for (const s of initiatives) {
const phase = s.phase.value || 'unknown';
const statusVal = s.status.value || 'unknown';
const conf = s.status.confidence === 'explicit' ? '(explicit)' : '(inferred)';
const status = `${statusVal} ${conf}`;
const context = s.nextAction.value
? s.nextAction.value
: s.lastArtifact.file
? `Last: ${s.lastArtifact.file} (${s.lastArtifact.date || '?'})`
: 'No artifacts';
lines.push(`| ${s.initiative} | ${phase} | ${status} | ${context} |`);
}
return lines.join('\n') + '\n';
}
module.exports = { formatMarkdown };
/**
* Terminal formatter — aligned column output with confidence markers.
* No library used — padEnd() for alignment.
*
* @module terminal-formatter
*/
/**
* Format InitiativeState array as aligned terminal table.
*
* @param {import('../../types').InitiativeState[]} initiatives
* @returns {string}
*/
function formatTerminal(initiatives) {
if (initiatives.length === 0) {
return 'No initiatives found.';
}
// Dynamic init column width: at least 14, grows for long names
const maxInitLen = Math.max(14, ...initiatives.map(s => s.initiative.length + 2));
const COL = { init: maxInitLen, phase: 12, status: 24, action: 50 };
const lines = [];
// Header
lines.push(
'Initiative'.padEnd(COL.init) +
'Phase'.padEnd(COL.phase) +
'Status'.padEnd(COL.status) +
'Next Action / Context'
);
lines.push('-'.repeat(COL.init + COL.phase + COL.status + COL.action));
for (const s of initiatives) {
const phase = s.phase.value || 'unknown';
const statusVal = s.status.value || 'unknown';
const conf = s.status.confidence === 'explicit' ? '(explicit)' : '(inferred)';
const status = `${statusVal} ${conf}`;
const context = s.nextAction.value
? s.nextAction.value
: s.lastArtifact.file
? `Last: ${s.lastArtifact.file} (${s.lastArtifact.date || '?'})`
: 'No artifacts';
lines.push(
s.initiative.padEnd(COL.init) +
phase.padEnd(COL.phase) +
status.padEnd(COL.status) +
context
);
}
return lines.join('\n');
}
module.exports = { formatTerminal };
#!/usr/bin/env node
/**
* Convoke Portfolio Engine — scan → parse → infer → sort → format → output.
* Read-only: no git writes, no file modifications.
*
* @module portfolio-engine
*/
const fs = require('fs-extra');
const path = require('path');
const {
readTaxonomy,
scanArtifactDirs,
parseFrontmatter,
inferArtifactType,
inferInitiative
} = require('../artifact-utils');
const { findProjectRoot } = require('../../update/lib/utils');
const { applyFrontmatterRule } = require('./rules/frontmatter-rule');
const { applyArtifactChainRule } = require('./rules/artifact-chain-rule');
const { applyGitRecencyRule } = require('./rules/git-recency-rule');
const { applyConflictResolver } = require('./rules/conflict-resolver');
const { formatTerminal } = require('./formatters/terminal-formatter');
const { formatMarkdown } = require('./formatters/markdown-formatter');
/** Directories to exclude from portfolio scan */
const EXCLUDE_DIRS = ['_archive', 'brainstorming', 'design-artifacts', 'journey-examples', 'project-documentation', 'test-artifacts', 'drafts'];
/**
* Create an empty InitiativeState for a given initiative.
* @param {string} initiative - Initiative ID
* @returns {import('../types').InitiativeState}
*/
function makeEmptyState(initiative) {
return {
initiative,
phase: { value: null, source: null, confidence: null },
status: { value: null, source: null, confidence: null },
lastArtifact: { file: null, date: null },
nextAction: { value: null, source: null }
};
}
/**
* Generate portfolio view of all initiatives.
*
* @param {string} projectRoot - Absolute path to project root
* @param {Object} [options={}]
* @param {string} [options.sort='alpha'] - Sort mode: 'alpha' or 'last-activity'
* @param {number} [options.staleDays=30] - Days threshold for stale detection
* @returns {Promise<{initiatives: import('../types').InitiativeState[], summary: {total: number, governed: number, ungoverned: number}}>}
*/
async function generatePortfolio(projectRoot, options = {}) {
const { sort = 'alpha', staleDays = 30, wipThreshold = 4, filter = null } = options;
// Pre-flight: read taxonomy (FR39 — error if absent)
const taxonomy = readTaxonomy(projectRoot);
// Scan: discover subdirectories dynamically
const outputDir = path.join(projectRoot, '_bmad-output');
if (!fs.existsSync(outputDir)) {
console.warn('Warning: _bmad-output/ directory not found.');
return { initiatives: [], summary: { total: 0, governed: 0, ungoverned: 0 } };
}
const allDirs = fs.readdirSync(outputDir, { withFileTypes: true })
.filter(e => e.isDirectory() && !e.name.startsWith('.') && !EXCLUDE_DIRS.includes(e.name))
.map(e => e.name);
const allFiles = await scanArtifactDirs(projectRoot, allDirs, ['_archive']);
// Parse: index files by initiative
const registry = new Map();
let governed = 0;
let ungoverned = 0;
let unattributed = 0;
const mdFiles = allFiles.filter(f => f.filename.endsWith('.md'));
for (const file of mdFiles) {
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
let frontmatter = null;
let content = '';
try {
content = fs.readFileSync(file.fullPath, 'utf8');
frontmatter = parseFrontmatter(content).data;
} catch {
// Unreadable — treat as no frontmatter
}
// Governed = has frontmatter with matching initiative field
const isGoverned = !!(frontmatter && frontmatter.initiative && frontmatter.initiative === initResult.initiative);
if (isGoverned) {
governed++;
} else {
ungoverned++;
}
const enriched = {
filename: file.filename,
dir: file.dir,
fullPath: file.fullPath,
type: typeResult.type,
hcPrefix: typeResult.hcPrefix,
date: typeResult.date,
initiative: initResult.initiative,
frontmatter,
content,
isGoverned,
degradedMode: !isGoverned
};
if (!registry.has(initResult.initiative)) {
registry.set(initResult.initiative, []);
}
registry.get(initResult.initiative).push(enriched);
}
// FR39: warn if no governed artifacts
if (governed === 0 && mdFiles.length > 0) {
console.warn('Warning: No governed artifacts found. Run migration to populate.');
}
// Infer: run rule chain for each initiative in taxonomy
const allInitiatives = [...taxonomy.initiatives.platform, ...taxonomy.initiatives.user];
let results = [];
for (const initiative of allInitiatives) {
const artifacts = registry.get(initiative) || [];
let state = makeEmptyState(initiative);
state = applyFrontmatterRule(state, artifacts, { projectRoot });
state = applyArtifactChainRule(state, artifacts, { projectRoot });
state = applyGitRecencyRule(state, artifacts, { projectRoot, staleDays });
state = applyConflictResolver(state, artifacts, { projectRoot });
results.push(state);
}
// Sort
if (sort === 'last-activity') {
results.sort((a, b) => (b.lastArtifact.date || '').localeCompare(a.lastArtifact.date || ''));
} else {
results.sort((a, b) => a.initiative.localeCompare(b.initiative));
}
// Filter by initiative prefix (before WIP count)
if (filter) {
const prefix = filter.replace(/\*$/, '');
results = results.filter(s => s.initiative.startsWith(prefix));
}
// WIP radar: count active initiatives (ongoing, blocked, or stale)
const activeStatuses = ['ongoing', 'stale', 'blocked'];
const activeInitiatives = results.filter(s => activeStatuses.includes(s.status.value));
const wipRadar = activeInitiatives.length > wipThreshold
? {
active: activeInitiatives.length,
threshold: wipThreshold,
initiatives: activeInitiatives
.sort((a, b) => (b.lastArtifact.date || '').localeCompare(a.lastArtifact.date || ''))
.map(s => s.initiative)
}
: null;
// Calculate governance health score (of attributable files only — excludes unattributed)
const attributable = governed + ungoverned;
const healthPercentage = attributable > 0 ? Math.round((governed / attributable) * 100) : 0;
return {
initiatives: results,
wipRadar,
summary: {
total: mdFiles.length,
governed,
ungoverned,
unattributed,
healthScore: { governed, total: attributable, percentage: healthPercentage }
}
};
}
// --- CLI ---
function printHelp() {
console.log(`
Usage: convoke-portfolio [options]
Generate a portfolio view of all initiatives from artifact analysis.
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
Examples:
convoke-portfolio Default terminal view
convoke-portfolio --markdown Markdown output for chat/docs
convoke-portfolio --sort last-activity Sort by most recent activity
`);
}
async function main() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
printHelp();
return;
}
const projectRoot = findProjectRoot();
if (!projectRoot) {
console.error('Error: Not in a Convoke project. Could not find _bmad/ directory.');
process.exit(1);
}
const useMarkdown = args.includes('--markdown');
const useVerbose = args.includes('--verbose');
const sortMode = args.includes('--sort') && args[args.indexOf('--sort') + 1] === 'last-activity'
? 'last-activity'
: 'alpha';
const filterIdx = args.indexOf('--filter');
const filterPattern = (filterIdx !== -1 && args[filterIdx + 1] && !args[filterIdx + 1].startsWith('--'))
? args[filterIdx + 1]
: null;
// Read portfolio config from _bmad/bmm/config.yaml (optional)
let wipThreshold = 4;
let staleDays = 30;
try {
const yaml = require('js-yaml');
const configPath = path.join(projectRoot, '_bmad', 'bmm', 'config.yaml');
if (fs.existsSync(configPath)) {
const config = yaml.load(fs.readFileSync(configPath, 'utf8'));
if (config && config.portfolio) {
const wt = Number(config.portfolio.wip_threshold);
if (!isNaN(wt)) wipThreshold = wt;
const sd = Number(config.portfolio.stale_days);
if (!isNaN(sd)) staleDays = sd;
}
}
} catch {
// Config read failed — use defaults
}
try {
const result = await generatePortfolio(projectRoot, {
sort: sortMode,
filter: filterPattern,
wipThreshold,
staleDays
});
const output = useMarkdown
? formatMarkdown(result.initiatives)
: formatTerminal(result.initiatives);
console.log(output);
// WIP radar (only when threshold exceeded)
if (result.wipRadar) {
console.log(`\nWIP: ${result.wipRadar.active} active (threshold: ${result.wipRadar.threshold}) -- sorted by last activity`);
console.log(` ${result.wipRadar.initiatives.join(', ')}`);
}
console.log(`\nTotal: ${result.summary.total} artifacts | Governed: ${result.summary.governed} | Ungoverned: ${result.summary.ungoverned} | Unattributed: ${result.summary.unattributed}`);
const hs = result.summary.healthScore;
console.log(`Governance: ${hs.governed}/${hs.total} artifacts governed (${hs.percentage}%)`);
// Verbose: inference trace per initiative
if (useVerbose) {
console.log('\n--- Inference Trace ---');
for (const s of result.initiatives) {
const p = s.phase;
const st = s.status;
console.log(` [${s.initiative}] phase: ${p.value} (${p.source}, ${p.confidence}) | status: ${st.value} (${st.source}, ${st.confidence})`);
}
}
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
}
if (require.main === module) {
main().catch(err => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
}
module.exports = { generatePortfolio, makeEmptyState, EXCLUDE_DIRS };
/**
* Rule 2: Infer phase from artifact chain analysis.
*
* Priority order (highest first):
* 1. Epic with all stories done/complete/✅/[x]/strikethrough → complete
* 2. Epic + sprint artifact → build
* 3. Architecture doc → planning
* 4. HC artifacts (HC2-HC6) → discovery
* 5. PRD or brief only → planning
* 6. No recognized artifacts → unknown
*
* Also detects Vortex HC chain completeness (FR34).
*
* @param {import('../../types').InitiativeState} state - Current initiative state
* @param {Array<{filename: string, dir: string, fullPath: string, type?: string, hcPrefix?: string, date?: string, content?: string}>} artifacts
* @param {Object} _options - Reserved
* @returns {import('../../types').InitiativeState} Enriched state
*/
/** Patterns that indicate epic completion — require status context to avoid false positives.
* Matches: "status: done", "epic-1: done", "Status:** done", "- [x]", "✅" */
const DONE_PATTERNS = [
/(?:status|epic)[^:]*:\s*done\b/i,
/(?:status|epic)[^:]*:\s*complete\b/i,
/\*\*\s*done\b/i, // bold marker: **done**
/✅/,
/\[x\]/i,
/~~[^~]{3,}~~/ // strikethrough (min 3 chars to avoid false matches)
];
function applyArtifactChainRule(state, artifacts, _options = {}) {
// Don't override explicit frontmatter phase
if (state.phase.confidence === 'explicit') return state;
const types = new Set(artifacts.map(a => a.type).filter(Boolean));
const hcPrefixes = new Set(artifacts.map(a => a.hcPrefix).filter(Boolean));
// Track last artifact for this initiative
let latestArtifact = null;
let latestDate = '';
for (const a of artifacts) {
const d = a.date || '';
if (d >= latestDate) {
latestDate = d;
latestArtifact = a;
}
}
if (latestArtifact) {
state.lastArtifact = { file: latestArtifact.filename, date: latestDate || 'unknown' };
}
// Check for epic completion
const epicArtifacts = artifacts.filter(a => a.type === 'epic');
if (epicArtifacts.length > 0) {
// Use most recent epic (by date, fallback to last in array)
const epic = epicArtifacts.reduce((best, a) => {
return (a.date || '') >= (best.date || '') ? a : best;
}, epicArtifacts[0]);
if (epic.content && isEpicDone(epic.content)) {
state.phase = { value: 'complete', source: 'artifact-chain', confidence: 'inferred' };
return state;
}
// Epic exists + sprint artifact → build
if (types.has('sprint')) {
state.phase = { value: 'build', source: 'artifact-chain', confidence: 'inferred' };
return state;
}
}
// Architecture doc → planning
if (types.has('arch')) {
state.phase = { value: 'planning', source: 'artifact-chain', confidence: 'inferred' };
// Detect HC chain for nextAction even in planning phase
detectHCChain(state, hcPrefixes);
return state;
}
// HC artifacts → discovery
if (hcPrefixes.size > 0) {
state.phase = { value: 'discovery', source: 'artifact-chain', confidence: 'inferred' };
detectHCChain(state, hcPrefixes);
return state;
}
// PRD or brief → planning
if (types.has('prd') || types.has('brief')) {
state.phase = { value: 'planning', source: 'artifact-chain', confidence: 'inferred' };
return state;
}
// No recognized pattern
state.phase = { value: 'unknown', source: 'artifact-chain', confidence: 'inferred' };
return state;
}
/**
* Check if epic content indicates completion via flexible markers.
* @param {string} content - Epic file content
* @returns {boolean}
*/
function isEpicDone(content) {
return DONE_PATTERNS.some(pattern => pattern.test(content));
}
/**
* Detect Vortex HC chain completeness and set nextAction if gaps found.
* HC chain: HC2 (Problem Definition) → HC3 (Hypothesis) → HC4 (Experiment) → HC5 (Signal) → HC6 (Decision)
* @param {import('../../types').InitiativeState} state
* @param {Set<string>} hcPrefixes - Set of HC prefixes present (e.g., 'hc2', 'hc3')
*/
function detectHCChain(state, hcPrefixes) {
const expectedHCs = ['hc2', 'hc3', 'hc4', 'hc5', 'hc6'];
const hcNames = { hc2: 'Problem Definition', hc3: 'Hypothesis', hc4: 'Experiment', hc5: 'Signal', hc6: 'Decision' };
const missing = expectedHCs.filter(hc => !hcPrefixes.has(hc));
if (missing.length === 0) {
state.nextAction = { value: 'HC chain complete — ready for learning decision', source: 'chain-gap' };
} else if (missing.length < expectedHCs.length) {
const nextMissing = missing[0];
state.nextAction = { value: `Next: ${hcNames[nextMissing]} (${nextMissing.toUpperCase()})`, source: 'chain-gap' };
}
}
module.exports = { applyArtifactChainRule, isEpicDone, detectHCChain, DONE_PATTERNS };
/**
* Rule 4: Resolve conflicts between inference signals.
*
* - Explicit confidence always wins over inferred
* - For same confidence: later phase overrides earlier
* - Ensures lastArtifact and nextAction are populated
*
* @param {import('../../types').InitiativeState} state - Current initiative state
* @param {Array<{filename: string, dir: string, fullPath: string}>} artifacts - Artifacts for this initiative
* @param {Object} _options - Reserved
* @returns {import('../../types').InitiativeState} Enriched state
*/
/** Phase priority order (higher index = later phase) */
const PHASE_PRIORITY = ['unknown', 'discovery', 'planning', 'build', 'complete'];
function applyConflictResolver(state, artifacts, _options = {}) {
// Ensure phase has a value
if (!state.phase.value) {
state.phase = { value: 'unknown', source: 'conflict-resolver', confidence: 'inferred' };
}
// Ensure status has a value
if (!state.status.value) {
state.status = { value: 'unknown', source: 'conflict-resolver', confidence: 'inferred' };
}
// Ensure lastArtifact is populated
if (!state.lastArtifact.file && artifacts.length > 0) {
// Fallback to last artifact in array
const last = artifacts[artifacts.length - 1];
state.lastArtifact = { file: last.filename, date: last.date || 'unknown' };
}
// Derive nextAction from phase if not already set by chain-gap analysis
if (!state.nextAction.value) {
state.nextAction = deriveNextAction(state);
}
return state;
}
/**
* Derive a suggested next action based on current phase.
* @param {import('../../types').InitiativeState} state
* @returns {{value: string, source: string}}
*/
function deriveNextAction(state) {
switch (state.phase.value) {
case 'unknown':
return { value: 'Create PRD or brief to start planning', source: 'conflict-resolver' };
case 'discovery':
return { value: 'Continue discovery — check HC chain progress', source: 'conflict-resolver' };
case 'planning':
return { value: 'Create architecture or epics to advance to build', source: 'conflict-resolver' };
case 'build':
return { value: 'Continue story execution', source: 'conflict-resolver' };
case 'complete':
return { value: 'Initiative complete — consider retrospective', source: 'conflict-resolver' };
default:
return { value: 'Review initiative status', source: 'conflict-resolver' };
}
}
/**
* Compare two phases by priority.
* @param {string} a - Phase name
* @param {string} b - Phase name
* @returns {number} Negative if a < b, positive if a > b, 0 if equal
*/
function comparePhasePriority(a, b) {
const idxA = PHASE_PRIORITY.indexOf(a);
const idxB = PHASE_PRIORITY.indexOf(b);
return (idxA === -1 ? -1 : idxA) - (idxB === -1 ? -1 : idxB);
}
module.exports = { applyConflictResolver, deriveNextAction, comparePhasePriority, PHASE_PRIORITY };
/**
* Rule 1: Read explicit status/phase from frontmatter (highest priority).
*
* Reads `status` (standard schema field) and `phase` (operator-override, not in standard schema).
* Explicit frontmatter signals have highest priority — later rules should not override them.
*
* @param {import('../../types').InitiativeState} state - Current initiative state
* @param {Array<{filename: string, dir: string, fullPath: string, frontmatter?: Object}>} artifacts - Artifacts for this initiative
* @param {Object} _options - Reserved
* @returns {import('../../types').InitiativeState} Enriched state
*/
function applyFrontmatterRule(state, artifacts, _options = {}) {
// Process artifacts most-recent-first (by date suffix or array order)
// First explicit value found wins
for (const artifact of artifacts) {
if (!artifact.frontmatter) continue;
if (!state.status.value || state.status.confidence !== 'explicit') {
if (artifact.frontmatter.status != null && artifact.frontmatter.status !== '') {
state.status = {
value: artifact.frontmatter.status,
source: 'frontmatter',
confidence: 'explicit'
};
}
}
if (!state.phase.value || state.phase.confidence !== 'explicit') {
if (artifact.frontmatter.phase != null && artifact.frontmatter.phase !== '') {
state.phase = {
value: artifact.frontmatter.phase,
source: 'frontmatter',
confidence: 'explicit'
};
}
}
}
return state;
}
module.exports = { applyFrontmatterRule };
/**
* Rule 3: Infer status from git recency (stale detection).
*
* Checks the most recent git commit date for the initiative's artifacts.
* If within stale_days → ongoing. If beyond → stale.
*
* Known limitation: checks current branch only.
*
* @param {import('../../types').InitiativeState} state - Current initiative state
* @param {Array<{filename: string, dir: string, fullPath: string}>} artifacts - Artifacts for this initiative
* @param {Object} options
* @param {number} [options.staleDays=30] - Days threshold for stale detection
* @param {string} options.projectRoot - Absolute path to project root
* @returns {import('../../types').InitiativeState} Enriched state
*/
const { execFileSync } = require('child_process');
const path = require('path');
function applyGitRecencyRule(state, artifacts, options = {}) {
// Don't override explicit frontmatter status
if (state.status.confidence === 'explicit') return state;
const { staleDays = 30, projectRoot } = options;
if (!projectRoot || artifacts.length === 0) return state;
// Find most recent git activity across all artifacts for this initiative
let latestDate = null;
let latestFile = null;
for (const artifact of artifacts) {
try {
const relativePath = path.relative(projectRoot, artifact.fullPath);
const dateStr = execFileSync(
'git', ['log', '-1', '--format=%as', '--', relativePath],
{ cwd: projectRoot, encoding: 'utf8', stdio: 'pipe' }
).trim();
if (dateStr && (!latestDate || dateStr > latestDate)) {
latestDate = dateStr;
latestFile = artifact.filename;
}
} catch {
// File not tracked or git unavailable — skip
}
}
if (!latestDate) return state;
// Update lastArtifact if git date is more recent than artifact-chain's date
if (!state.lastArtifact.file || latestDate > (state.lastArtifact.date || '')) {
state.lastArtifact = { file: latestFile, date: latestDate };
}
// Calculate days since last activity
const lastActivity = new Date(latestDate);
const now = new Date();
const daysSince = Math.floor((now - lastActivity) / (1000 * 60 * 60 * 24));
if (daysSince <= staleDays) {
state.status = { value: 'ongoing', source: 'git-recency', confidence: 'inferred' };
} else {
state.status = { value: 'stale', source: 'git-recency', confidence: 'inferred' };
}
return state;
}
module.exports = { applyGitRecencyRule };
/**
* Shared JSDoc type definitions for the Artifact Governance & Portfolio system.
* Used by: artifact-utils.js, migrate-artifacts.js, portfolio-engine.js, archive.js
*
* @module types
*/
/**
* Inference signal with value, source, and confidence level.
* @typedef {Object} InferenceSignal
* @property {string} value - The inferred value
* @property {string} source - Where the inference came from (e.g., 'frontmatter', 'artifact-chain', 'git-recency')
* @property {'explicit'|'inferred'} confidence - Whether this is from explicit data or heuristic inference
*/
/**
* State of an initiative as derived by the portfolio inference rule chain.
* This is the core data structure that flows through the rule chain and into formatters.
* @typedef {Object} InitiativeState
* @property {string} initiative - Initiative ID from taxonomy (e.g., 'helm', 'gyre')
* @property {InferenceSignal} phase - Current phase: discovery, planning, build, blocked, complete, unknown
* @property {InferenceSignal} status - Current status: ongoing, blocked, paused, complete, stale, unknown
* @property {{file: string, date: string}} lastArtifact - Most recently modified artifact for this initiative
* @property {{value: string, source: string}} nextAction - Suggested next action based on chain gap analysis
*/
/**
* @deprecated Use ManifestEntry instead. Planning placeholder with incomplete fields.
* @typedef {Object} RenameManifestEntry
* @property {string} oldPath - Current file path (relative to project root)
* @property {string} newPath - Proposed new file path
* @property {string} initiative - Inferred initiative ID
* @property {string} artifactType - Inferred artifact type
* @property {'high'|'low'} confidence - Inference confidence level
* @property {'fully-governed'|'half-governed'|'ungoverned'|'invalid-governed'} governanceState - Current governance state
*/
/**
* Manifest entry for dry-run display. Replaces RenameManifestEntry.
* @typedef {Object} ManifestEntry
* @property {string} oldPath - Current relative path (e.g., 'planning-artifacts/prd-gyre.md')
* @property {string|null} newPath - Proposed new path (null for SKIP/CONFLICT/AMBIGUOUS)
* @property {string|null} initiative - Resolved initiative ID (null if ambiguous)
* @property {string|null} artifactType - Resolved artifact type (null if ungoverned)
* @property {'high'|'low'} confidence - Initiative inference confidence
* @property {string} source - Inference source (exact/alias/empty/unresolved)
* @property {'RENAME'|'SKIP'|'INJECT_ONLY'|'CONFLICT'|'AMBIGUOUS'} action
* @property {string} dir - Directory name (e.g., 'planning-artifacts')
* @property {{firstLines: string[], gitAuthor: string|null, gitDate: string|null}|null} contextClues
* @property {string[]|null} crossReferences - Files referencing this one (verbose only)
* @property {string[]} candidates - Possible initiative matches (ambiguous only)
* @property {string[]|null} collisionWith - Other files colliding on same newPath
* @property {string|null} frontmatterInitiative - Initiative from frontmatter (for CONFLICT display)
* @property {string|null} fileInitiative - Initiative from filename (for CONFLICT display)
* @property {'high'|'low'} typeConfidence - Artifact type inference confidence
* @property {string} typeSource - Artifact type inference source (prefix/alias/none)
*/
/**
* Result of generateManifest() containing all entries, collisions, and summary.
* @typedef {Object} ManifestResult
* @property {ManifestEntry[]} entries - All manifest entries
* @property {Map<string, string[]>} collisions - Colliding newPath -> list of oldPaths
* @property {{total: number, skip: number, rename: number, inject: number, conflict: number, ambiguous: number}} summary
*/
/**
* A markdown link that needs updating after file renames.
* @typedef {Object} LinkUpdate
* @property {string} filePath - Path of the file containing the link
* @property {string} oldLink - Original link target
* @property {string} newLink - Updated link target
* @property {'bracket-link'|'relative-link'|'parent-link'|'frontmatter-array'} pattern - Which link pattern matched
*/
/**
* Parsed taxonomy configuration from _bmad/_config/taxonomy.yaml.
* @typedef {Object} TaxonomyConfig
* @property {{platform: string[], user: string[]}} initiatives - Initiative IDs split by ownership
* @property {string[]} artifact_types - Valid artifact type identifiers
* @property {Object<string, string>} aliases - Historical name → canonical initiative ID mapping (migration-only)
*/
/**
* Frontmatter metadata fields for governed artifacts.
* @typedef {Object} FrontmatterSchema
* @property {string} initiative - Initiative ID from taxonomy
* @property {string} artifact_type - Artifact type from taxonomy
* @property {'draft'|'validated'|'superseded'|'active'} [status] - Optional artifact-level status
* @property {string} created - ISO 8601 date string (YYYY-MM-DD)
* @property {number} schema_version - Schema version integer (currently 1)
*/
/**
* Result of parsing a filename against naming conventions.
* @typedef {Object} ParsedFilename
* @property {string} filename - Original filename
* @property {boolean} isDated - Whether the file has a date suffix
* @property {string|null} date - Extracted date (YYYY-MM-DD) or null
* @property {string} baseName - Filename without date and extension
* @property {string|null} category - Extracted category prefix or null
* @property {boolean} hasValidCategory - Whether category is in the valid list
* @property {boolean} isUppercase - Whether filename contains uppercase characters
* @property {boolean} matchesConvention - Whether filename fully matches naming convention
*/
/**
* Result of a frontmatter conflict check.
* @typedef {Object} FrontmatterConflict
* @property {string} field - The conflicting field name
* @property {*} existingValue - Current value in frontmatter
* @property {*} newValue - Proposed new value
*/
/**
* Result of injectFrontmatter() including conflict detection.
* @typedef {Object} InjectResult
* @property {string} content - The modified file content with injected frontmatter
* @property {FrontmatterConflict[]} conflicts - Any field conflicts detected (empty if none)
*/
module.exports = {};
#!/usr/bin/env node
/**
* Convoke Artifact Governance Migration CLI
*
* Dry-run by default — shows what the migration would do without changing anything.
* Use --apply to execute renames. Use --apply --force to skip confirmation.
*
* @module migrate-artifacts
*/
const fs = require('fs-extra');
const path = require('path');
const yaml = require('js-yaml');
const { findProjectRoot } = require('./update/lib/utils');
const {
readTaxonomy,
generateManifest,
formatManifest,
ensureCleanTree,
executeRenames,
ArtifactMigrationError,
verifyHistoryChain,
executeInjections,
resolveAmbiguous,
detectMigrationState,
generateGovernanceADR,
supersedePreviousADR
} = require('./lib/artifact-utils');
// --- CLI Argument Parsing ---
const DEFAULT_INCLUDE_DIRS = Object.freeze(['planning-artifacts', 'vortex-artifacts', 'gyre-artifacts']);
/** Pattern for valid directory names: lowercase alphanumeric, dashes, underscores */
const VALID_DIR_PATTERN = /^[a-zA-Z0-9_-]+$/;
/**
* Parse CLI arguments from argv array.
*
* @param {string[]} argv - Arguments (typically process.argv.slice(2))
* @returns {{help: boolean, includeDirs: string[], apply: boolean, force: boolean, verbose: boolean}}
*/
function parseArgs(argv) {
const help = argv.includes('--help') || argv.includes('-h');
const apply = argv.includes('--apply');
const force = argv.includes('--force');
const verbose = argv.includes('--verbose');
let includeDirs = [...DEFAULT_INCLUDE_DIRS];
const includeIdx = argv.indexOf('--include');
if (includeIdx !== -1) {
const nextArg = argv[includeIdx + 1];
// Skip if next arg is missing or is another flag
if (nextArg && !nextArg.startsWith('--')) {
const parsed = nextArg.split(',').map(d => d.trim()).filter(Boolean);
// Validate: only simple directory names (no path traversal)
const valid = parsed.filter(d => VALID_DIR_PATTERN.test(d));
const invalid = parsed.filter(d => !VALID_DIR_PATTERN.test(d));
if (invalid.length > 0) {
console.warn(`Warning: Invalid directory names ignored: ${invalid.join(', ')}`);
}
if (valid.length > 0) {
includeDirs = valid;
}
}
}
return { help, includeDirs, apply, force, verbose };
}
// --- Help ---
function printHelp() {
console.log(`
Usage: convoke-migrate-artifacts [options]
Analyze artifact files and show what the governance migration would do.
Dry-run by default — no files are modified.
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
Examples:
convoke-migrate-artifacts Dry-run with default scope
convoke-migrate-artifacts --verbose Dry-run with cross-references
convoke-migrate-artifacts --include planning-artifacts Dry-run for one directory
`);
}
// --- Taxonomy Bootstrap ---
const PLATFORM_INITIATIVES = ['vortex', 'gyre', 'bmm', 'forge', 'helm', 'enhance', 'loom', 'convoke'];
const DEFAULT_ARTIFACT_TYPES = [
'prd', 'epic', 'arch', 'adr', 'persona', 'lean-persona', 'empathy-map',
'problem-def', 'hypothesis', 'experiment', 'signal', 'decision', 'scope',
'pre-reg', 'sprint', 'brief', 'vision', 'report', 'research', 'story', 'spec'
];
/**
* Create taxonomy.yaml with platform defaults if it does not exist.
* Idempotent — never overwrites an existing file.
*
* @param {string} projectRoot - Absolute path to project root
* @returns {boolean} true if file was created, false if already existed
*/
function bootstrapTaxonomy(projectRoot) {
const configDir = path.join(projectRoot, '_bmad', '_config');
const configPath = path.join(configDir, 'taxonomy.yaml');
if (fs.existsSync(configPath)) {
return false;
}
const defaults = {
initiatives: {
platform: PLATFORM_INITIATIVES,
user: []
},
artifact_types: DEFAULT_ARTIFACT_TYPES,
aliases: {}
};
const header = [
'# Artifact Governance Taxonomy Configuration',
'# Schema version: 1',
`# Created by: convoke-migrate-artifacts bootstrap`,
'#',
'# This file is the single source of truth for initiative IDs, artifact types,',
'# and historical name aliases used by the governance system.',
''
].join('\n');
fs.ensureDirSync(configDir);
fs.writeFileSync(configPath, header + yaml.dump(defaults, { lineWidth: -1 }), 'utf8');
return true;
}
// --- Interactive Prompt ---
/**
* Prompt the operator to confirm migration apply.
* Exported for mocking in tests — tests should NEVER interact with real readline.
*
* @returns {Promise<boolean>} true if operator confirms
*/
async function confirmApply() {
const readline = require('readline');
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
return new Promise(resolve => {
rl.on('close', () => resolve(false)); // piped/closed stdin → reject
rl.question('Apply migration? [y/n] ', answer => {
rl.close();
resolve(typeof answer === 'string' && answer.trim().toLowerCase() === 'y');
});
});
}
// --- Main ---
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
return;
}
if (args.force && !args.apply) {
console.log('Warning: --force has no effect without --apply. Running dry-run instead.');
}
const projectRoot = findProjectRoot();
if (!projectRoot) {
console.error('Error: Not in a Convoke project. Could not find _bmad/ directory.');
process.exit(1);
}
// Archive exclusion (FR50): always strip _archive from includeDirs
const excludeDirs = ['_archive'];
const filteredIncludeDirs = args.includeDirs.filter(d => {
if (d === '_archive') {
console.warn('Warning: _archive is always excluded from migration scope (FR50). Ignoring.');
return false;
}
return true;
});
if (filteredIncludeDirs.length === 0) {
console.error('Error: No directories to scan. All specified directories were excluded.');
process.exit(1);
}
// Taxonomy bootstrap (FR49): create if absent, never overwrite
const created = bootstrapTaxonomy(projectRoot);
if (created) {
console.log('Created _bmad/_config/taxonomy.yaml with platform defaults.');
}
// Validate taxonomy (NFR22: graceful error, no stack traces)
try {
readTaxonomy(projectRoot);
} catch (err) {
console.error(`Error: Invalid taxonomy configuration.`);
console.error(` ${err.message}`);
console.error(` Fix the file at: _bmad/_config/taxonomy.yaml`);
process.exit(1);
}
// Generate manifest (shared by dry-run and apply)
const manifest = await generateManifest(projectRoot, {
includeDirs: filteredIncludeDirs,
excludeDirs,
verbose: args.verbose
});
const output = formatManifest(manifest, { verbose: args.verbose });
console.log(output);
// Dry-run mode (default): just print manifest and exit
if (!args.apply) {
return;
}
// --- Apply mode ---
// Idempotent recovery detection
let migrationState = detectMigrationState(projectRoot);
if (migrationState === 'complete') {
// Secondary check: verify manifest confirms all files governed (catches new files added since migration)
const hasWork = manifest.entries.some(e => e.action === 'RENAME' || e.action === 'AMBIGUOUS');
if (!hasWork) {
console.log('\nNothing to migrate -- all files governed.');
return;
}
// New files found — proceed as fresh migration
console.log('\nPrevious migration detected, but new ungoverned files found. Proceeding with fresh migration.');
migrationState = 'fresh';
}
// Load taxonomy for ambiguous resolution
const taxonomy = readTaxonomy(projectRoot);
// Resolve ambiguous files interactively (or auto-skip in --force mode)
const resolution = await resolveAmbiguous(manifest, taxonomy, projectRoot, { force: args.force });
if (resolution.resolved > 0 || resolution.skipped > 0) {
console.log(`\nAmbiguous resolution: ${resolution.resolved} resolved, ${resolution.skipped} skipped.`);
}
// Re-compute counts and re-check collisions after resolution (new RENAME entries may collide)
const { detectCollisions } = require('./lib/artifact-utils');
manifest.collisions = detectCollisions(manifest.entries);
const renameCount = manifest.summary.rename;
const skipCount = manifest.entries.filter(e => e.action === 'SKIP').length;
const ambiguousLeft = manifest.summary.ambiguous;
console.log(`\n${renameCount} files will be renamed. ${skipCount} skipped. ${ambiguousLeft} still ambiguous.`);
// Block on collisions (includes post-resolution collisions)
if (manifest.collisions.size > 0) {
console.error(`Error: ${manifest.collisions.size} filename collision(s) detected. Resolve before applying.`);
process.exit(1);
}
if (renameCount === 0) {
console.log('Nothing to rename.');
return;
}
// Confirmation prompt (unless --force)
if (!args.force) {
const confirmed = await confirmApply();
if (!confirmed) {
console.log('Migration aborted.');
return;
}
}
// Pre-flight: ensure clean tree
try {
ensureCleanTree(filteredIncludeDirs, projectRoot);
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
// Execute migration phases
try {
// Phase routing based on idempotent recovery state
if (migrationState === 'renames-done') {
const priorCount = manifest.entries.filter(e => e.action === 'RENAME').length;
console.log(`\nDetected partial migration (${priorCount} renames done, frontmatter pending). Resuming commit 2.`);
} else {
// Commit 1: renames
const renameResult = executeRenames(manifest, projectRoot);
console.log(`\nRename phase complete. ${renameResult.renamedCount} files renamed. Commit: ${renameResult.commitSha}`);
// Verify history chain (informational)
const renamedEntries = manifest.entries.filter(e => e.action === 'RENAME');
const verification = verifyHistoryChain(renamedEntries, projectRoot);
if (verification.failed.length > 0) {
console.warn(`Warning: git log --follow failed for ${verification.failed.length} file(s):`);
for (const f of verification.failed) {
console.warn(` ${f}`);
}
} else {
console.log(`History chain verified for ${verification.verified} sample file(s).`);
}
}
// Commit 2: frontmatter injection + link updating + rename map
const injResult = await executeInjections(manifest, projectRoot, filteredIncludeDirs);
console.log(`\nInjection phase complete. ${injResult.injectedCount} files injected, ${injResult.linkUpdates.updatedLinks} links updated, ${injResult.conflictCount} conflicts skipped. Commit: ${injResult.commitSha}`);
// Commit 3: ADR generation (non-blocking — failure logs warning, doesn't rollback)
try {
const date = new Date().toISOString().split('T')[0];
const newADRFilename = `adr-artifact-governance-convention-${date}.md`;
const adrContent = generateGovernanceADR(date, {
renamedCount: renameCount,
injectedCount: injResult.injectedCount,
linksUpdated: injResult.linkUpdates.updatedLinks,
scopeDirs: filteredIncludeDirs
});
const adrDir = path.join(projectRoot, '_bmad-output', 'planning-artifacts');
fs.ensureDirSync(adrDir);
const adrPath = path.join(adrDir, newADRFilename);
// Write new ADR FIRST, then supersede old (prevents orphaned supersession if write fails)
fs.writeFileSync(adrPath, adrContent, 'utf8');
supersedePreviousADR(projectRoot, newADRFilename);
const { execFileSync: execGit } = require('child_process');
execGit('git', ['add', '_bmad-output/planning-artifacts/'], { cwd: projectRoot, stdio: 'pipe' });
execGit('git', ['commit', '-m', 'chore: generate governance convention ADR'], { cwd: projectRoot, stdio: 'pipe' });
console.log(`\nADR generated: ${newADRFilename}`);
} catch (adrErr) {
console.warn(`\nWarning: ADR generation failed: ${adrErr.message}`);
console.warn('Migration data is intact (commits 1-2 preserved). ADR can be generated manually.');
}
// Final summary
console.log(`\nMigration complete. ${renameCount} files renamed, ${injResult.injectedCount} frontmatter injected, ${injResult.linkUpdates.updatedLinks} links updated, ${skipCount} skipped.`);
} catch (err) {
if (err instanceof ArtifactMigrationError && err.phase === 'rename') {
console.error(`\nRename failed: ${err.message}`);
if (err.recoverable) {
console.error('Rollback complete. No changes made.');
} else {
console.error('WARNING: Rollback may have failed. Run `git status` to check working tree state.');
}
process.exit(1);
}
if (err instanceof ArtifactMigrationError && err.phase === 'inject') {
console.error(`\nInjection failed: ${err.message}`);
if (err.recoverable) {
console.error('Renames preserved (commit 1). Injections discarded.');
} else {
console.error('WARNING: Rollback may have failed. Run `git status` to check working tree state.');
}
process.exit(1);
}
throw err;
}
}
// Run if invoked directly
if (require.main === module) {
main().catch(err => {
console.error(`Error: ${err.message}`);
process.exit(1);
});
}
module.exports = { parseArgs, main, confirmApply, bootstrapTaxonomy, DEFAULT_INCLUDE_DIRS, PLATFORM_INITIATIVES, DEFAULT_ARTIFACT_TYPES, VALID_DIR_PATTERN };
const fs = require('fs-extra');
const path = require('path');
const yaml = require('js-yaml');
/**
* Platform-canonical taxonomy defaults.
* Mirrors migrate-artifacts.js constants (separate module boundary).
*/
const PLATFORM_INITIATIVES = ['vortex', 'gyre', 'bmm', 'forge', 'helm', 'enhance', 'loom', 'convoke'];
const DEFAULT_ARTIFACT_TYPES = [
'prd', 'epic', 'arch', 'adr', 'persona', 'lean-persona', 'empathy-map',
'problem-def', 'hypothesis', 'experiment', 'signal', 'decision', 'scope',
'pre-reg', 'sprint', 'brief', 'vision', 'report', 'research', 'story', 'spec'
];
const DEFAULT_ALIASES = {
'strategy-perimeter': 'helm',
'strategy': 'helm',
'strategic': 'helm',
'strategic-navigator': 'helm',
'strategic-practitioner': 'helm',
'team-factory': 'loom'
};
const TAXONOMY_HEADER = [
'# Artifact Governance Taxonomy Configuration',
'# Schema version: 1',
'# Managed by: convoke-update taxonomy merger',
'#',
'# This file is the single source of truth for initiative IDs, artifact types,',
'# and historical name aliases used by the governance system.',
''
].join('\n');
/**
* Create or merge taxonomy.yaml with platform defaults.
* Idempotent: safe to run multiple times.
*
* - If absent: creates with platform defaults
* - If present: merges platform entries (adds missing, preserves user additions)
* - Promotes user initiative IDs to platform when they match (FR42)
*
* @param {string} projectRoot - Absolute path to project root
* @returns {Promise<{created: boolean, merged: boolean, promoted: string[]}>}
*/
async function mergeTaxonomy(projectRoot) {
const configDir = path.join(projectRoot, '_bmad', '_config');
const configPath = path.join(configDir, 'taxonomy.yaml');
await fs.ensureDir(configDir);
// If no taxonomy exists, create from scratch
if (!await fs.pathExists(configPath)) {
const defaults = {
initiatives: { platform: [...PLATFORM_INITIATIVES], user: [] },
artifact_types: [...DEFAULT_ARTIFACT_TYPES],
aliases: { ...DEFAULT_ALIASES }
};
await fs.writeFile(configPath, TAXONOMY_HEADER + yaml.dump(defaults, { lineWidth: -1 }), 'utf8');
return { created: true, merged: false, promoted: [] };
}
// Read existing taxonomy (handle corrupt YAML gracefully, matching config-merger pattern)
const content = await fs.readFile(configPath, 'utf8');
let existing;
try {
existing = yaml.load(content) || {};
} catch {
console.warn('Warning: taxonomy.yaml contains invalid YAML. Treating as empty and merging defaults.');
existing = {};
}
// Ensure structure
if (!existing.initiatives) existing.initiatives = {};
if (!Array.isArray(existing.initiatives.platform)) existing.initiatives.platform = [];
if (!Array.isArray(existing.initiatives.user)) existing.initiatives.user = [];
if (!Array.isArray(existing.artifact_types)) existing.artifact_types = [];
if (!existing.aliases || typeof existing.aliases !== 'object') existing.aliases = {};
let merged = false;
const promoted = [];
// Merge platform initiatives (add missing)
const platformSet = new Set(existing.initiatives.platform);
for (const id of PLATFORM_INITIATIVES) {
if (!platformSet.has(id)) {
existing.initiatives.platform.push(id);
merged = true;
}
}
// Promote user IDs that match platform (FR42)
const date = new Date().toISOString().split('T')[0];
const newPlatformSet = new Set(existing.initiatives.platform);
existing.initiatives.user = existing.initiatives.user.filter(userId => {
if (newPlatformSet.has(userId)) {
promoted.push(userId);
return false; // Remove from user (already in platform)
}
return true;
});
// Merge artifact types (add missing)
const typeSet = new Set(existing.artifact_types);
for (const type of DEFAULT_ARTIFACT_TYPES) {
if (!typeSet.has(type)) {
existing.artifact_types.push(type);
merged = true;
}
}
// Merge aliases (add missing, don't overwrite existing)
for (const [key, value] of Object.entries(DEFAULT_ALIASES)) {
if (!(key in existing.aliases)) {
existing.aliases[key] = value;
merged = true;
}
}
// Write back if changes were made
if (merged || promoted.length > 0) {
let output = TAXONOMY_HEADER + yaml.dump(existing, { lineWidth: -1 });
// Add promotion comments
if (promoted.length > 0) {
for (const id of promoted) {
output += `# ${id}: promoted from user section on ${date}\n`;
}
}
await fs.writeFile(configPath, output, 'utf8');
}
return { created: false, merged: merged || promoted.length > 0, promoted };
}
module.exports = { mergeTaxonomy, PLATFORM_INITIATIVES, DEFAULT_ARTIFACT_TYPES, DEFAULT_ALIASES };
const { mergeTaxonomy } = require('../lib/taxonomy-merger');
/**
* Migration: 2.0.x → 3.1.0
* Introduces artifact governance taxonomy configuration.
* Creates or merges _bmad/_config/taxonomy.yaml with platform defaults.
* Idempotent — safe to re-run.
*/
module.exports = {
name: '2.0.x-to-3.1.0',
fromVersion: '2.0.x',
breaking: false,
async preview() {
return {
actions: [
'Create or merge _bmad/_config/taxonomy.yaml with platform defaults',
'Add 8 platform initiative IDs (vortex, gyre, bmm, forge, helm, enhance, loom, convoke)',
'Add 21 artifact type identifiers',
'Add 6 historical name aliases for migration',
'Promote any user initiative IDs that match new platform IDs'
]
};
},
/**
* Apply migration: create or merge taxonomy config.
* @param {string} projectRoot - Absolute path to project root
* @returns {Promise<Array<string>>} List of changes made
*/
async apply(projectRoot) {
const changes = [];
const result = await mergeTaxonomy(projectRoot);
if (result.created) {
changes.push('Created _bmad/_config/taxonomy.yaml with platform defaults');
} else if (result.merged) {
changes.push('Merged platform entries into existing taxonomy.yaml');
} else {
changes.push('Taxonomy already up to date — no changes needed');
}
if (result.promoted.length > 0) {
changes.push(`Promoted ${result.promoted.length} user initiative(s) to platform: ${result.promoted.join(', ')}`);
}
return changes;
}
};
const { mergeTaxonomy } = require('../lib/taxonomy-merger');
/**
* Migration: 3.0.x → 3.1.0
* Parallel entry for 3.0.x users (same logic as 2.0.x-to-3.1.0).
* Introduces artifact governance taxonomy configuration.
*/
module.exports = {
name: '3.0.x-to-3.1.0',
fromVersion: '3.0.x',
breaking: false,
async preview() {
return {
actions: [
'Create or merge _bmad/_config/taxonomy.yaml with platform defaults',
'Add platform initiative IDs, artifact types, and aliases if missing'
]
};
},
async apply(projectRoot) {
const changes = [];
const result = await mergeTaxonomy(projectRoot);
if (result.created) {
changes.push('Created _bmad/_config/taxonomy.yaml with platform defaults');
} else if (result.merged) {
changes.push('Merged platform entries into existing taxonomy.yaml');
} else {
changes.push('Taxonomy already up to date — no changes needed');
}
if (result.promoted.length > 0) {
changes.push(`Promoted ${result.promoted.length} user initiative(s) to platform: ${result.promoted.join(', ')}`);
}
return changes;
}
};
+1
-1

@@ -36,3 +36,3 @@ submodule_name: _vortex

- vortex-navigation
version: 3.0.4
version: 3.1.0
user_name: '{user}'

@@ -39,0 +39,0 @@ communication_language: en

@@ -10,2 +10,31 @@ # Changelog

## [3.1.0] - 2026-04-06
### Added
- **Artifact Governance Migration** (`convoke-migrate-artifacts`) — Full transactional migration pipeline that renames artifacts to `{initiative}-{type}[-qualifier][-date].md` convention, injects frontmatter metadata, updates internal links, and generates a governance ADR. Supports dry-run (default), `--apply` with interactive confirmation, `--force` for automation, `--include` for scope control, and `--verbose` for cross-references.
- **Portfolio Intelligence** (`convoke-portfolio`) — Read-only initiative portfolio showing phase, status, next action, and context for every initiative. Features 4 inference rules (frontmatter, artifact-chain, git-recency, conflict-resolver), degraded mode for ungoverned artifacts, governance health score, WIP overload radar, `--filter` prefix filtering, `--sort last-activity`, `--verbose` inference trace, and terminal + markdown output formats.
- **Taxonomy Configuration** (`_bmad/_config/taxonomy.yaml`) — Single source of truth for initiative IDs, artifact types, and historical name aliases. Created automatically by `convoke-update` (migration 2.0.x/3.0.x-to-3.1.0) or `convoke-migrate-artifacts`.
- **convoke-doctor taxonomy validation** — 6 new health checks: file exists, valid YAML, structure, ID format, no duplicates, no initiative/type collisions.
- **convoke-update taxonomy merger** — Automatic taxonomy creation on upgrade from pre-3.1.0. Merges platform entries without overwriting user additions. Promotes user initiative IDs to platform when they become official (FR42).
- **convoke-check** (`npm run check`) — Local CI mirror that runs lint + unit + integration + Jest lib tests before push. Prevents CI failures from reaching GitHub Actions.
- **Workflow frontmatter adoption** — `bmad-create-prd` and `bmad-create-epics-and-stories` templates now include governance frontmatter fields (initiative, artifact_type, status, created, schema_version). Graceful degradation when taxonomy absent.
- **BMAD portfolio skill** (`.claude/skills/bmad-portfolio-status/`) — Thin wrapper invoking `convoke-portfolio --markdown` for chat/skill context.
- **Artifact rename map** — `artifact-rename-map.md` generated during migration with old-to-new filename mapping.
- **Governance ADR** — Migration generates `adr-artifact-governance-convention-{date}.md` documenting the naming convention and supersedes the previous repo organization ADR.
### Changed
- **Frontmatter schema v1** — All governed artifacts include: `initiative`, `artifact_type`, `created` (ISO date), `schema_version: 1`. Optional: `status` (draft/validated/superseded/active).
- **Shell injection prevention** — All `execSync` calls migrated to `execFileSync` with argument arrays across `artifact-utils.js` and `ensureCleanTree`.
- **convoke-doctor** — Now exports `checkTaxonomy` for testability; uses `require.main === module` guard.
### Infrastructure
- **320+ tests** across unit (Node built-in), integration, and Jest lib suites
- **50 FRs and 22 NFRs** addressed across 18 stories in 5 epics
- **5 retrospectives** with compounding process improvements
---
## [3.0.0] - 2026-03-25

@@ -12,0 +41,0 @@

{
"name": "convoke-agents",
"version": "3.0.4",
"version": "3.1.0",
"description": "Agent teams for complex systems, compatible with BMad Method",

@@ -26,3 +26,5 @@ "main": "index.js",

"convoke-migrate": "scripts/update/convoke-migrate.js",
"convoke-doctor": "scripts/convoke-doctor.js"
"convoke-doctor": "scripts/convoke-doctor.js",
"convoke-migrate-artifacts": "scripts/migrate-artifacts.js",
"convoke-portfolio": "scripts/lib/portfolio/portfolio-engine.js"
},

@@ -40,3 +42,4 @@ "scripts": {

"docs:audit": "node scripts/docs-audit.js",
"archive": "node scripts/archive.js"
"archive": "node scripts/archive.js",
"check": "node scripts/convoke-check.js"
},

@@ -77,2 +80,3 @@ "keywords": [

"fs-extra": "^11.3.3",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0"

@@ -79,0 +83,0 @@ },

+14
-13

@@ -13,3 +13,3 @@ <div align="center">

[![Version](https://img.shields.io/badge/version-3.0.0-blue)](https://github.com/amalik/convoke-agents)
[![Version](https://img.shields.io/badge/version-3.0.4-blue)](https://github.com/amalik/convoke-agents)
[![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)

@@ -264,3 +264,4 @@

```bash
npm install convoke-agents && npx -p convoke-agents convoke-install
npm install convoke-agents@latest
npx convoke-install
```

@@ -271,3 +272,4 @@

```bash
npm install convoke-agents && npx -p convoke-agents convoke-install-vortex
npm install convoke-agents@latest
npx convoke-install-vortex
```

@@ -278,6 +280,7 @@

```bash
npm install convoke-agents && npx -p convoke-agents convoke-install-gyre
npm install convoke-agents@latest
npx convoke-install-gyre
```
Something not working? Run `npx -p convoke-agents convoke-doctor` or check the [FAQ](docs/faq.md).
Something not working? Run `npx convoke-doctor` or check the [FAQ](docs/faq.md).

@@ -389,6 +392,7 @@ ### Personalize

```bash
npx -p convoke-agents convoke-version # Check current version
npx -p convoke-agents convoke-update --dry-run # Preview changes
npx -p convoke-agents convoke-update # Apply update (auto-backup)
npx -p convoke-agents convoke-doctor # Diagnose issues
npm install convoke-agents@latest # Get the latest package
npx convoke-version # Check current version
npx convoke-update --dry-run # Preview changes
npx convoke-update # Apply update (auto-backup)
npx convoke-doctor # Diagnose issues
```

@@ -398,6 +402,3 @@

> **Tip:** If `npx convoke-update` reports "Already up to date" but you know a newer version exists, npx may be serving a cached copy. Force the latest with:
> ```bash
> npx -p convoke-agents@latest convoke-update --yes
> ```
> **Important:** `npm install convoke-agents` (without `@latest`) won't cross major version boundaries. If you're on v2.x, you must use `npm install convoke-agents@latest` to get v3.x.

@@ -404,0 +405,0 @@ See [UPDATE-GUIDE.md](UPDATE-GUIDE.md) for migration paths and troubleshooting.

@@ -6,12 +6,10 @@ #!/usr/bin/env node

const { findProjectRoot } = require('./update/lib/utils');
const {
parseFilename,
NAMING_PATTERN,
toLowerKebab,
ensureCleanTree,
scanArtifactDirs
} = require('./lib/artifact-utils');
// --- Category registry from ADR ---
const VALID_CATEGORIES = [
'prd', 'epic', 'arch', 'adr', 'brief', 'report', 'spec', 'vision',
'hc', 'persona', 'experiment', 'learning', 'sprint', 'decision',
'research'
];
const NAMING_PATTERN = /^[a-z][a-z0-9-]*\.(?:md|yaml)$/;
// Living documents that are exempt from the category prefix requirement

@@ -31,33 +29,2 @@ const EXEMPT_FILES = [

function isValidCategory(cat) {
const base = cat.replace(/\d+$/, '');
return VALID_CATEGORIES.includes(base) || VALID_CATEGORIES.includes(cat);
}
const DATED_PATTERN = /^(.+)-(\d{4}-\d{2}-\d{2})\.(md|yaml)$/;
const CATEGORIZED_PATTERN = /^([a-z]+\d*)-(.+)\.(md|yaml)$/;
// --- Helpers ---
function parseFilename(filename) {
const lower = filename.toLowerCase();
const dated = lower.match(DATED_PATTERN);
const categorized = lower.match(CATEGORIZED_PATTERN);
return {
filename,
isDated: !!dated,
date: dated ? dated[2] : null,
baseName: dated ? dated[1] : lower.replace(/\.(md|yaml)$/, ''),
category: categorized ? categorized[1] : null,
hasValidCategory: categorized ? isValidCategory(categorized[1]) : false,
isUppercase: filename !== lower,
matchesConvention: NAMING_PATTERN.test(filename) && categorized && isValidCategory(categorized[1])
};
}
function toLowerKebab(filename) {
return filename.toLowerCase();
}
function groupByKey(files) {

@@ -122,2 +89,12 @@ const groups = {};

// Clean tree check — only when applying changes (dry-run is safe)
if (apply) {
try {
ensureCleanTree(SCAN_DIRS, projectRoot);
} catch (err) {
console.error(`❌ ${err.message}`);
process.exit(1);
}
}
const outputDir = path.join(projectRoot, '_bmad-output');

@@ -130,10 +107,14 @@ const archiveDir = path.join(outputDir, '_archive');

// 1. Scan subdirectories for superseded dated files
const scannedFiles = await scanArtifactDirs(projectRoot, SCAN_DIRS);
// Group scanned files by directory for per-dir processing
const filesByDir = {};
for (const f of scannedFiles) {
if (!filesByDir[f.dir]) filesByDir[f.dir] = [];
filesByDir[f.dir].push({ ...parseFilename(f.filename), dir: f.dir, fullPath: f.fullPath });
}
for (const dir of SCAN_DIRS) {
const fullDir = path.join(outputDir, dir);
if (!fs.existsSync(fullDir)) continue;
const files = filesByDir[dir] || [];
const files = (await fs.readdir(fullDir))
.filter(f => !f.startsWith('.'))
.map(f => ({ ...parseFilename(f), dir }));
// Find superseded versions

@@ -140,0 +121,0 @@ const groups = groupByKey(files);

@@ -73,2 +73,3 @@ #!/usr/bin/env node

checks.push(checkVersionConsistency(projectRoot, modules));
checks.push(...checkTaxonomy(projectRoot));

@@ -371,5 +372,132 @@ printResults(checks);

main().catch(err => {
console.error(chalk.red(`Doctor failed: ${err.message}`));
process.exit(1);
});
// --- Taxonomy Validation ---
/** Valid ID pattern: lowercase alphanumeric with optional dashes */
const TAXONOMY_ID_PATTERN = /^[a-z][a-z0-9-]*$/;
/**
* Validate taxonomy configuration file.
* Returns array of check results (never throws).
* @param {string} projectRoot
* @returns {Array<{name: string, passed: boolean, error?: string, warning?: string, fix?: string, info?: string}>}
*/
function checkTaxonomy(projectRoot) {
const results = [];
const configPath = path.join(projectRoot, '_bmad', '_config', 'taxonomy.yaml');
// Check 1: file exists
if (!fs.existsSync(configPath)) {
results.push({
name: 'Taxonomy: file exists',
passed: false,
warning: 'taxonomy.yaml not found at _bmad/_config/taxonomy.yaml',
fix: 'Run convoke-migrate-artifacts or convoke-update to create it'
});
return results;
}
results.push({ name: 'Taxonomy: file exists', passed: true });
// Check 2: YAML parseable
let config;
try {
config = yaml.load(fs.readFileSync(configPath, 'utf8'));
} catch (err) {
results.push({
name: 'Taxonomy: valid YAML',
passed: false,
error: `Invalid YAML in taxonomy.yaml: ${err.message}`,
fix: 'Fix the YAML syntax in _bmad/_config/taxonomy.yaml'
});
return results;
}
results.push({ name: 'Taxonomy: valid YAML', passed: true });
if (!config || typeof config !== 'object') {
results.push({ name: 'Taxonomy: structure', passed: false, error: 'taxonomy.yaml is empty or not an object' });
return results;
}
// Check 3: required sections
const issues = [];
if (!config.initiatives || !Array.isArray(config.initiatives.platform)) {
issues.push('Missing initiatives.platform (must be an array)');
}
if (!config.initiatives || !Array.isArray(config.initiatives.user)) {
issues.push('Missing initiatives.user (must be an array)');
}
if (!Array.isArray(config.artifact_types)) {
issues.push('Missing artifact_types (must be an array)');
}
if (issues.length > 0) {
results.push({ name: 'Taxonomy: structure', passed: false, error: issues.join('; ') });
return results;
}
results.push({ name: 'Taxonomy: structure', passed: true });
// Check 4: ID format validation
const allPlatform = config.initiatives.platform || [];
const allUser = config.initiatives.user || [];
const allTypes = config.artifact_types || [];
const invalidIds = [];
for (const id of [...allPlatform, ...allUser]) {
if (!TAXONOMY_ID_PATTERN.test(id)) {
invalidIds.push(`initiative "${id}"`);
}
}
for (const id of allTypes) {
if (!TAXONOMY_ID_PATTERN.test(id)) {
invalidIds.push(`artifact_type "${id}"`);
}
}
if (invalidIds.length > 0) {
results.push({
name: 'Taxonomy: ID format',
passed: false,
error: `Invalid IDs (must be lowercase alphanumeric with dashes): ${invalidIds.join(', ')}`
});
} else {
results.push({ name: 'Taxonomy: ID format', passed: true });
}
// Check 5: duplicates between platform and user
const platformSet = new Set(allPlatform);
const duplicates = allUser.filter(id => platformSet.has(id));
if (duplicates.length > 0) {
results.push({
name: 'Taxonomy: no duplicates',
passed: false,
error: `Duplicate IDs in both platform and user sections: ${duplicates.join(', ')}`,
fix: 'Remove duplicates from the user section (they are already in platform)'
});
} else {
results.push({ name: 'Taxonomy: no duplicates', passed: true });
}
// Check 6: collisions between initiatives and artifact types
const allInitiatives = new Set([...allPlatform, ...allUser]);
const collisions = allTypes.filter(t => allInitiatives.has(t));
if (collisions.length > 0) {
results.push({
name: 'Taxonomy: no collisions',
passed: false,
error: `IDs used as both initiative and artifact type: ${collisions.join(', ')}`,
fix: 'Rename the colliding IDs to be unique across sections'
});
} else {
results.push({ name: 'Taxonomy: no collisions', passed: true });
}
return results;
}
if (require.main === module) {
main().catch(err => {
console.error(chalk.red(`Doctor failed: ${err.message}`));
process.exit(1);
});
}
module.exports = { checkTaxonomy };

@@ -469,3 +469,3 @@ #!/usr/bin/env node

console.log(chalk.green('✓ Installation restored from backup'));
} catch (restoreError) {
} catch (_restoreError) {
console.error(chalk.red('✗ Restore failed!'));

@@ -472,0 +472,0 @@ console.error(chalk.yellow(`Manual restore may be needed from: ${backupMetadata.backup_dir}`));

@@ -70,2 +70,16 @@ #!/usr/bin/env node

module: null
},
{
name: '2.0.x-to-3.1.0',
fromVersion: '2.0.x',
breaking: false,
description: 'Artifact governance: create or merge _bmad/_config/taxonomy.yaml with platform defaults, aliases, and artifact types',
module: null
},
{
name: '3.0.x-to-3.1.0',
fromVersion: '3.0.x',
breaking: false,
description: 'Artifact governance: create or merge _bmad/_config/taxonomy.yaml (parallel entry for 3.0.x users)',
module: null
}

@@ -72,0 +86,0 @@ // Future migrations: append here. Only add delta logic for version-specific changes.