convoke-agents
Advanced tools
| # 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; | ||
| } | ||
| }; |
@@ -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 |
+29
-0
@@ -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 @@ |
+7
-3
| { | ||
| "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"> | ||
| [](https://github.com/amalik/convoke-agents) | ||
| [](https://github.com/amalik/convoke-agents) | ||
| [](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. |
+26
-45
@@ -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. |
Shell access
Supply chain riskThis module accesses the system shell. Accessing the system shell increases the risk of executing arbitrary code.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1654441
11.8%338
4.97%7865
58.12%510
0.2%4
33.33%4
33.33%4
Infinity%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added